Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update error handling for Apollo 4 #2483

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/components/forms/GSForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import PropTypes from "prop-types";
import React from "react";
import Form from "react-formal";
import { StyleSheet, css } from "aphrodite";
import { GraphQLRequestError } from "../../network/errors";
import { log } from "../../lib";
import withMuiTheme from "../../containers/hoc/withMuiTheme";

Expand Down Expand Up @@ -41,9 +40,7 @@ class GSForm extends React.Component {
}

handleFormError(err) {
if (err instanceof GraphQLRequestError) {
this.setState({ globalErrorMessage: err.message });
} else if (err.message) {
if (err.message) {
this.setState({ globalErrorMessage: err.message });
} else {
log.error(err);
Expand Down
49 changes: 26 additions & 23 deletions src/containers/AssignmentTexterContact.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,29 +141,32 @@ export class AssignmentTexterContact extends React.Component {
};

handleSendMessageError = e => {
// NOTE: status codes don't currently work so all errors will appear
// as "Something went wrong" keeping this code in here because
// we want to replace status codes with Apollo 2 error codes.
if (e.status === 402) {
this.goBackToTodos();
} else if (e.status === 400) {
const newState = {
snackbarError: e.message
};

if (e.message === "Your assignment has changed") {
newState.snackbarActionTitle = "Back to todos";
newState.snackbarOnClick = this.goBackToTodos;
this.setState(newState);
} else {
// opt out or send message Error
this.setState({
disabled: true,
disabledText: e.message
});
this.skipContact();
}
} else {
const error_code = e.graphQLErrors[0].code;
if (error_code === 'SENDERR_ASSIGNMENTCHANGED') {
this.setState({
snackbarError: e.message,
snackbarActionTitle: "Back to todos",
snackbarOnClick: this.goBackToTodos,
});
}
else if (error_code === 'SENDERR_OPTEDOUT') {
this.setState({
disabled: true,
disabledText: e.message,
snackbarError: e.message,
});
this.handleEditStatus('closed', false);
this.skipContact();
}
else if (error_code === 'SENDERR_OFFHOURS') {
this.setState({
disabled: true,
disabledText: e.message,
snackbarError: e.message,
});
this.skipContact();
}
else {
console.error(e);
this.setState({
disabled: true,
Expand Down
34 changes: 0 additions & 34 deletions src/network/errors.js

This file was deleted.

26 changes: 15 additions & 11 deletions src/server/api/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { r, cacheableData } from "../models";

export function authRequired(user) {
if (!user) {
throw new GraphQLError("You must login to access that resource.");
throw new GraphQLError("You must login to access that resource.", {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
}

Expand All @@ -27,11 +31,11 @@ export async function accessRequired(
role
);
if (!hasRole) {
const error = new GraphQLError(
"You are not authorized to access that resource."
);
error.code = "UNAUTHORIZED";
throw error;
throw new GraphQLError("You are not authorized to access that resource.", {
extensions: {
code: 'UNAUTHORIZED',
},
});
}
}

Expand Down Expand Up @@ -73,11 +77,11 @@ export async function assignmentRequiredOrAdminRole(
roleRequired
);
if (!hasPermission) {
const error = new GraphQLError(
"You are not authorized to access that resource."
);
error.code = "UNAUTHORIZED";
throw error;
throw new GraphQLError("You are not authorized to access that resource.", {
extensions: {
code: 'UNAUTHORIZED',
},
});
}
return userHasAssignment || true;
}
18 changes: 10 additions & 8 deletions src/server/api/mutations/joinOrganization.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { getConfig } from "../lib/config";
import telemetry from "../../telemetry";

const INVALID_JOIN = () => {
const error = new GraphQLError("Invalid join request");
error.code = "INVALID_JOIN";
return error;
return new GraphQLError("Invalid join request", {
extensions: {
code: 'INVALID_JOIN',
},
});
};

// eslint-disable-next-line import/prefer-default-export
Expand Down Expand Up @@ -43,11 +45,11 @@ export const joinOrganization = async (
r.knex("assignment").where("campaign_id", campaignId)
);
if (campaignTexterCount >= maxTextersPerCampaign) {
const error = new GraphQLError(
"Sorry, this campaign has too many texters already"
);
error.code = "FAILEDJOIN_TOOMANYTEXTERS";
throw error;
throw new GraphQLError("Sorry, this campaign has too many texters already.", {
extensions: {
code: 'FAILEDJOIN_TOOMANYTEXTERS',
},
});
}
}
} else {
Expand Down
7 changes: 5 additions & 2 deletions src/server/api/mutations/sendMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ const JOBS_SAME_PROCESS = !!(
);

const newError = (message, code, details = {}) => {
const err = new GraphQLError(message);
err.code = code;
const err = new GraphQLError(message, {
extensions: {
code: code,
},
});
if (process.env.DEBUGGING_EMAILS) {
sendEmail({
to: process.env.DEBUGGING_EMAILS.split(","),
Expand Down
26 changes: 12 additions & 14 deletions src/server/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,7 @@ const rootMutations = {
.limit(1);

if (!lastMessage) {
const errorStatusAndMessage = {
status: 400,
message:
"Cannot fake a reply to a contact that has no existing thread yet"
};
throw new GraphQLError(errorStatusAndMessage);
throw new GraphQLError("Cannot fake a reply to a contact that has no existing thread yet");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just add a code here like in the others.

}

const userNumber = lastMessage.user_number;
Expand Down Expand Up @@ -1455,20 +1450,23 @@ const rootMutations = {
join_token: joinToken,
})
.first();
const INVALID_REASSIGN = () => {
const error = new GraphQLError("Invalid reassign request - organization not found");
error.code = "INVALID_REASSIGN";
return error;
};
if (!campaign) {
throw INVALID_REASSIGN();
throw new GraphQLError("Invalid reassign request - campaign not found", {
extensions: {
code: 'INVALID_REASSIGN',
},
});
}
const organization = await cacheableData.organization.load(
campaign.organization_id
);
if (!organization) {
throw INVALID_REASSIGN();
}
throw new GraphQLError("Invalid reassign request - organization not found", {
extensions: {
code: 'INVALID_REASSIGN',
},
});
}
const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization) ?? 200;
let d = new Date();
d.setHours(d.getHours() - 1);
Expand Down
28 changes: 16 additions & 12 deletions src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,25 @@ const server = new ApolloServer({
resolvers,
introspection: true,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
formatError: error => {
formatError: (formattedError, error) => {
log.error({
// TODO: request is no longer available in formatError, figure out
// another way to do this.
// userId: request.user && request.user.id,
code: error?.extensions?.code ?? 'INTERNAL_SERVER_ERROR',
error: formattedError,
msg: "GraphQL error"
});

if (process.env.SHOW_SERVER_ERROR || process.env.DEBUG) {
if (error instanceof GraphQLError) {
return error;
}
return new GraphQLError(error.message);
return formattedError;
}

return new GraphQLError(
error &&
error.originalError &&
error.originalError.code === "UNAUTHORIZED"
? "UNAUTHORIZED"
: "Internal server error"
);
// Strip out stacktrace and other potentially sensative details.
return {
message: formattedError.message,
Copy link
Collaborator

@schuyler1d schuyler1d Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my main concern is what formattedError.message is/can be if an unexpected (e.g. internal library) error happens. E.g. Can this leak a database connection error/failure? I think some courses would make us more confident of this:

  1. Don't include the error in this (non SHOW_SERVER_ERROR mode)
  2. Create a test (and maybe necessarily some test endpoint) that throws a pure JS new Error("sensitive error") and formattedError.message does not include "sensitive error" -- unfortunately, I don't expect this behavior to be born out.

I feel like the code line might still be possible to keep as long as this isn't a new (I'm not up on new JS stuff) thing that goes beyond Apollo frameworks, though I'm a bit nervous of that -- best to whitelist a specific set of codes or only allow it if error typeof 'GraphQLError' -- i.e. something we've consciously wrapped (and you have explicit sets of codes above).

The thing we must block is the ability for a hacker to probe the app for ways to trigger a specific kind of failure or learn more info in the contexts of auth of which stage it fails in (e.g. authentication vs authorization).

[deleted and re-created comment from before]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely want to find the right balance. There's just a lot of places where giving users errors like "That invitation is no longer valid" or "Not allowed to add contacts after the campaign starts" is a lot more helpful than "Internal server error".

Another possible option, which I thought about but didn't test, would be to create our own error class that extends GraphQLError and throw that instead in the places where we want the message to be shown. Then we could check for that type in formatError.

Not 100% sure if that's possible, but I think it might be.

code: formattedError?.extensions?.code ?? 'INTERNAL_SERVER_ERROR',
};
}
});

Expand Down