Files
bodyshop/client/src/graphql/apollo-error-handling.js
2026-01-21 11:54:06 -05:00

202 lines
6.0 KiB
JavaScript

import * as Sentry from "@sentry/react";
import { ErrorLink } from "@apollo/client/link/error";
import { CombinedGraphQLErrors, CombinedProtocolErrors } from "@apollo/client/errors";
import { print } from "graphql";
import { getMainDefinition } from "@apollo/client/utilities";
import { auth } from "../firebase/firebase.utils";
import { from, throwError } from "rxjs";
import { catchError, mergeMap } from "rxjs/operators";
const SENSITIVE_KEYS = new Set([
"password",
"token",
"authorization",
"accessToken",
"refreshToken",
"idToken",
"session"
]);
let refreshInFlight = null;
const refreshTokenOnce = () => {
if (!refreshInFlight) {
refreshInFlight = auth.currentUser ? auth.currentUser.getIdToken(true) : Promise.resolve(null);
refreshInFlight.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
};
const redactVariables = (variables) => {
if (!variables || typeof variables !== "object") return variables;
const out = { ...variables };
for (const key of Object.keys(out)) {
if (SENSITIVE_KEYS.has(key)) out[key] = "[REDACTED]";
}
return out;
};
const safePrintQuery = (query) => {
try {
return query ? print(query) : undefined;
} catch {
return undefined;
}
};
const isSubscriptionOperation = (operation) => {
try {
const def = getMainDefinition(operation.query);
return def?.kind === "OperationDefinition" && def?.operation === "subscription";
} catch {
return false;
}
};
const extractGraphQLErrors = ({ error, result }) => {
// Primary: Apollo Client 4+ GraphQL errors
if (CombinedGraphQLErrors.is(error)) return error.errors;
// Fallback: raw GraphQL response errors (what apollo-link-logger shows)
if (Array.isArray(result?.errors) && result.errors.length) return result.errors;
// Fallback: some “network-ish” errors still carry result.errors
if (Array.isArray(error?.result?.errors) && error.result.errors.length) return error.result.errors;
return null;
};
const hasUnauthCode = (errs) => Array.isArray(errs) && errs.some((e) => e?.extensions?.code === "UNAUTHENTICATED");
const errorLink = new ErrorLink(({ error, result, operation, forward }) => {
const operationName = operation?.operationName || "anonymous";
const ctx = operation?.getContext?.() || {};
const callerStack = ctx?.callerStack;
const variables = redactVariables(operation?.variables);
const queryText = safePrintQuery(operation?.query);
const gqlErrors = extractGraphQLErrors({ error, result });
const protocolErrors = CombinedProtocolErrors.is(error) ? error.errors : null;
// ---- Single auth-refresh retry (HTTP only, once) ----
// Only retry if:
// - we have GraphQL errors with UNAUTHENTICATED
// - not a subscription
// - not already retried
// - forward exists (so we can re-run)
if (forward && !isSubscriptionOperation(operation) && !ctx.__didAuthRetry && hasUnauthCode(gqlErrors)) {
operation.setContext({ ...ctx, __didAuthRetry: true });
return from(refreshTokenOnce()).pipe(
mergeMap(() => forward(operation)),
catchError(() => throwError(() => error))
);
}
// ---- GraphQL errors ----
if (gqlErrors?.length) {
// Make it show up in the “Errors” filter (red)
console.error(`[GraphQL error] ${operationName}`, {
message: gqlErrors[0]?.message
});
if (import.meta.env.DEV) {
console.groupCollapsed?.(`[GraphQL error details] ${operationName}`);
console.log({
operationName,
variables,
query: queryText,
callerStack
});
gqlErrors.forEach((e, i) => {
console.log(`Error #${i + 1}:`, {
message: e.message,
path: e.path,
locations: e.locations,
extensions: e.extensions
});
});
console.groupEnd?.();
}
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
scope.setContext(
"graphql.errors",
gqlErrors.map((e) => ({
message: e.message,
path: e.path,
locations: e.locations,
extensions: e.extensions
}))
);
Sentry.captureMessage(`[GraphQL] ${operationName}`);
});
// IMPORTANT: do NOT return forward(operation) unless intentionally retrying
return;
}
// ---- Protocol errors (rare, but good to log) ----
if (protocolErrors?.length) {
console.error(`[Protocol error] ${operationName}`, {
message: protocolErrors[0]?.message
});
if (import.meta.env.DEV) {
console.groupCollapsed?.(`[Protocol error details] ${operationName}`);
console.log({ operationName, variables, query: queryText, callerStack });
protocolErrors.forEach((e, i) => console.log(`Protocol Error #${i + 1}:`, e));
console.groupEnd?.();
}
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
scope.setContext("graphql.protocolErrors", protocolErrors);
Sentry.captureMessage(`[GraphQL Protocol] ${operationName}`);
});
return;
}
// ---- Network / other errors ----
if (error) {
console.error(`[Network error] ${operationName}`, error);
Sentry.withScope((scope) => {
scope.setLevel("error");
scope.setTag("graphql.operationName", operationName);
scope.setContext("graphql.request", {
operationName,
variables,
query: import.meta.env.DEV ? queryText : undefined
});
if (callerStack) scope.setContext("graphql.caller", { stack: callerStack });
Sentry.captureException(error);
});
}
});
export default errorLink;