diff --git a/client/src/graphql/apollo-error-handling.js b/client/src/graphql/apollo-error-handling.js index 62cbeaeb3..64676c849 100644 --- a/client/src/graphql/apollo-error-handling.js +++ b/client/src/graphql/apollo-error-handling.js @@ -1,45 +1,201 @@ import * as Sentry from "@sentry/react"; -import { onError } from "@apollo/client/link/error"; +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 errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { +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; - if (graphQLErrors?.length) { - graphQLErrors.forEach(({ message, locations, path }) => { - console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`); + 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 }); - Sentry.withScope((scope) => { - scope.setLevel("error"); - scope.setTag("graphql.operationName", operationName); - scope.setContext("graphql", { - operationName - // variables can contain PII; include only if you’re comfortable with it - // variables: operation.variables + if (import.meta.env.DEV) { + console.groupCollapsed?.(`[GraphQL error details] ${operationName}`); + console.log({ + operationName, + variables, + query: queryText, + callerStack }); - - graphQLErrors.forEach((err) => { - scope.setContext("graphql.error", { - message: err.message, - path: err.path, - locations: err.locations + gqlErrors.forEach((e, i) => { + console.log(`Error #${i + 1}:`, { + message: e.message, + path: e.path, + locations: e.locations, + extensions: e.extensions }); - Sentry.captureMessage(`[GraphQL] ${err.message}`); }); - }); - } - - if (networkError) { - console.log(`[Network error]: ${JSON.stringify(networkError)}`); + console.groupEnd?.(); + } Sentry.withScope((scope) => { scope.setLevel("error"); scope.setTag("graphql.operationName", operationName); - Sentry.captureException(networkError); + + 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; } - return forward(operation); + // ---- 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; diff --git a/client/src/utils/GraphQLClient.js b/client/src/utils/GraphQLClient.js index 621aee673..3c527a946 100644 --- a/client/src/utils/GraphQLClient.js +++ b/client/src/utils/GraphQLClient.js @@ -1,4 +1,3 @@ -// file: client/src/utils/GraphQLClient.js import { ApolloClient, ApolloLink, InMemoryCache, split } from "@apollo/client"; import { setContext } from "@apollo/client/link/context"; import { HttpLink } from "@apollo/client/link/http"; @@ -13,45 +12,73 @@ import { map } from "rxjs/operators"; import { auth } from "../firebase/firebase.utils"; import errorLink from "../graphql/apollo-error-handling"; +/** + * HTTP transport + */ const httpLink = new HttpLink({ uri: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT }); -const wsLink = new GraphQLWsLink( - createClient({ - url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS, - lazy: true, - connectionParams: async () => { - const token = auth.currentUser && (await auth.currentUser.getIdToken()); - return { - headers: { - authorization: token ? `Bearer ${token}` : "" - } - }; - } - }) -); +/** + * WS transport (graphql-ws) + * - Auth header is established at connect-time via connectionParams. + * - Reconnect behavior is handled here (NOT via RetryLink). + */ +const wsClient = createClient({ + url: import.meta.env.VITE_APP_GRAPHQL_ENDPOINT_WS, + lazy: true, + lazyCloseTimeout: 5000, -// Split based on operation type (subscription -> ws, everything else -> http) -const terminatingLink = split( - ({ query }) => { - const definition = getMainDefinition(query); - return definition.kind === "OperationDefinition" && definition.operation === "subscription"; + retryAttempts: Infinity, + shouldRetry: (eventOrError) => { + // Don't retry a clean close (1000); retry everything else + const code = eventOrError?.code; + if (typeof code === "number") return code !== 1000; + return true; }, - wsLink, - httpLink -); -// function TrackExecutionTime(operationName, timeMs) { -// keep your existing implementation/commented code here -// This signature now matches the call site. -// } + connectionParams: async () => { + const token = await auth.currentUser?.getIdToken(); + return token + ? { + headers: { + authorization: `Bearer ${token}` + } + } + : {}; + } +}); -// Apollo Client 4 uses RxJS under the hood; use pipe(map(...)) instead of .map(...) +const wsLink = new GraphQLWsLink(wsClient); + +/** + * DEV-only: attach a caller stack to every operation so errors can point back + * to the initiating module/component. + */ +const callerStackLink = import.meta.env.DEV + ? new ApolloLink((operation, forward) => { + const stack = new Error().stack; + + operation.setContext((prev) => ({ + ...prev, + callerStack: stack + })); + + return forward(operation); + }) + : ApolloLink.empty(); + +/** + * Round-trip timing (HTTP only) + * NOTE: Subscriptions are long-lived streams; timing/retry should not wrap them. + */ const roundTripLink = new ApolloLink((operation, forward) => { operation.setContext({ start: Date.now() }); - return forward(operation).pipe( + const obs = forward?.(operation); + if (!obs) return null; + + return obs.pipe( map((result) => { // const start = operation.getContext().start; // const timeMs = Date.now() - start; @@ -61,9 +88,12 @@ const roundTripLink = new ApolloLink((operation, forward) => { ); }); +/** + * Auth for HTTP requests (per-request token) + */ const authLink = setContext(async (_, { headers }) => { try { - const token = auth.currentUser && (await auth.currentUser.getIdToken()); + const token = await auth.currentUser?.getIdToken(); if (!token) return { headers }; return { @@ -78,28 +108,65 @@ const authLink = setContext(async (_, { headers }) => { } }); +/** + * Retry (HTTP only) — transient/network failures only. + * Avoid retrying deterministic GraphQL errors (validation, missing variables, etc). + */ const retryLink = new RetryLink({ delay: { initial: 500, - // Keeping your intent (a cap), but make it milliseconds (5ms is almost certainly not intended). max: 5000, jitter: true }, attempts: { max: 5, - retryIf: (error) => !!error + retryIf: (error) => { + // Apollo's network errors often expose statusCode/response.status + const status = error?.statusCode ?? error?.response?.status; + if (typeof status === "number") { + // Common transient HTTP statuses + return [408, 429, 500, 502, 503, 504].includes(status); + } + + // Fallback heuristic for fetch/socket transient failures + const msg = `${error?.message || ""}`; + return /network|fetch|timeout|ECONNRESET|ENOTFOUND|EAI_AGAIN/i.test(msg); + } } }); -const links = []; +/** + * Branch chains + * - HTTP branch includes timing + retry + auth. + * - WS branch is intentionally lean; graphql-ws handles reconnect. + * + * NOTE: + * errorLink is applied OUTSIDE the split so it sees both HTTP + WS operations. + * Placing errorLink upstream of retryLink means it generally logs only after retries are exhausted. + */ +const httpChain = ApolloLink.from([roundTripLink, retryLink, authLink, httpLink]); +const wsChain = ApolloLink.from([wsLink]); -if (import.meta.env.DEV) { - links.push(apolloLogger); -} +const isSubscriptionOperation = ({ query }) => { + const definition = getMainDefinition(query); + return definition.kind === "OperationDefinition" && definition.operation === "subscription"; +}; -// Order: timing -> retry -> error -> auth -> network split -links.push(roundTripLink, retryLink, errorLink, authLink, terminatingLink); +const terminatingLink = split(isSubscriptionOperation, wsChain, httpChain); +/** + * Final link chain + * - errorLink wraps both branches (HTTP + WS) + * - callerStackLink adds call-site stack (DEV only) + * - apollo-link-logger only in DEV + */ +const link = ApolloLink.from( + import.meta.env.DEV ? [apolloLogger, callerStackLink, errorLink, terminatingLink] : [errorLink, terminatingLink] +); + +/** + * Cache policies + */ const cache = new InMemoryCache({ typePolicies: { Query: { @@ -114,6 +181,7 @@ const cache = new InMemoryCache({ merged[offset + i] = incoming[i]; } + // Deduplicate by id (important when you also upsert via sockets) const seen = new Set(); return merged.filter((ref) => { const id = readField("id", ref); @@ -125,20 +193,30 @@ const cache = new InMemoryCache({ }, notifications: { - merge(existing = [], incoming = [], { readField }) { - const merged = new Map(); + // Keep "respect the current query filter" behavior (i.e., don't union with existing), + // but dedupe within the incoming page/list so UI doesn't flicker with duplicates. + // eslint-disable-next-line no-unused-vars + merge(_existing = [], incoming = [], { readField }) { + const seen = new Set(); + const out = []; - existing.forEach((item) => { - const ref = readField("__ref", item); - if (ref) merged.set(ref, item); - }); + for (const item of incoming) { + const typename = readField("__typename", item); + const id = readField("id", item); - incoming.forEach((item) => { - const ref = readField("__ref", item); - if (ref) merged.set(ref, item); - }); + const key = typename && id ? `${typename}:${id}` : null; - return incoming; + if (!key) { + out.push(item); + continue; + } + + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + + return out; } } } @@ -146,8 +224,11 @@ const cache = new InMemoryCache({ } }); +/** + * Client + */ const client = new ApolloClient({ - link: ApolloLink.from(links), + link, cache, devtools: { name: "Imex Client",