Align GraphQL error handling for billing and AI chat (#19690)
## What changed This refactor fixes AI chat error surfacing by aligning both the backend and frontend with the existing GraphQL error architecture instead of adding AI-local error translation. On the backend: - add a dedicated GraphQL billing exception path - register billing GraphQL handling globally for GraphQL requests - reuse the existing AI GraphQL interceptor path for agent/chat exceptions - keep billing status classification shared between REST and GraphQL - remove the earlier attempt to preserve `CustomException` metadata in the global GraphQL fallback On the frontend: - keep the original Apollo GraphQL error object in AI chat state - reuse shared Apollo/GraphQL helpers for user-facing messages and error-type checks - delete AI-specific error extraction helpers that duplicated generic GraphQL parsing - replace a few direct `extensions.subCode` call sites with a shared predicate ## Why it changed The original bug was that `BillingException` and AI exceptions thrown from chat were not being translated into GraphQL errors with the expected `extensions.subCode` and `extensions.userFriendlyMessage`, so the AI chat UI had nothing structured to inspect. An intermediate fix worked mechanically but pushed `CustomException` handling into the global GraphQL fallback, which blurred the intended layering. This PR moves the behavior back to explicit GraphQL edges. ## Root cause `AgentChatResolver` could throw `BillingException` and `AgentException`, but: - billing had a REST exception filter and no shared GraphQL equivalent - AI chat was not consistently using the same GraphQL exception translation path as the sibling AI resolver - the frontend chat UI had drifted into AI-specific error parsing instead of consuming the same structured Apollo errors as the rest of the app ## Impact - `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can render the existing credits-exhausted UI in chat - `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path - AI chat now follows the same general GraphQL error consumption pattern as the rest of the frontend - billing GraphQL handling is less dependent on individual resolver authors remembering to add a filter ## Validation - `yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts` - `yarn jest --config packages/twenty-front/jest.config.mjs packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts` - `npx oxlint --type-aware ...` on touched backend/frontend files - `npx prettier --check ...` on touched backend/frontend files ## Follow-up ideas - consolidate frontend GraphQL error helpers further so more existing direct `extensions.subCode` checks move to shared utilities - consider whether common GraphQL exception filter registration should live in a more explicit GraphQL-specific module instead of `CoreEngineModule` - add an end-to-end test for a real `sendChatMessage` GraphQL failure path in AI chat --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
|
||||
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
|
||||
|
||||
const createCombinedGraphQLError = (
|
||||
errors: Array<{
|
||||
message: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
}>,
|
||||
): CombinedGraphQLErrors => new CombinedGraphQLErrors({ errors, data: null });
|
||||
|
||||
describe('isGraphqlErrorOfType', () => {
|
||||
it('matches a subCode from an Apollo GraphQL error', () => {
|
||||
const error = createCombinedGraphQLError([
|
||||
{
|
||||
message: 'Credits exhausted',
|
||||
extensions: {
|
||||
code: 'FORBIDDEN',
|
||||
subCode: 'BILLING_CREDITS_EXHAUSTED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isGraphqlErrorOfType(error, 'BILLING_CREDITS_EXHAUSTED')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the GraphQL code when no subCode is present', () => {
|
||||
const error = createCombinedGraphQLError([
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isGraphqlErrorOfType(error, 'UNAUTHENTICATED')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a plain error code property', () => {
|
||||
const error = new Error('Stream failed') as Error & { code: string };
|
||||
|
||||
error.code = 'AGENT_EXECUTION_FAILED';
|
||||
|
||||
expect(isGraphqlErrorOfType(error, 'AGENT_EXECUTION_FAILED')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a subCode from a plain GraphQLFormattedError object', () => {
|
||||
const error = {
|
||||
message: 'Refresh token expired',
|
||||
extensions: {
|
||||
subCode: 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
isGraphqlErrorOfType(
|
||||
error,
|
||||
'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the error code does not match', () => {
|
||||
const error = createCombinedGraphQLError([
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isGraphqlErrorOfType(error, 'FORBIDDEN')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getGraphqlErrorExtensionsFromError = (
|
||||
error: unknown,
|
||||
): Record<string, unknown> | undefined => {
|
||||
if (CombinedGraphQLErrors.is(error)) {
|
||||
const extensions = error.errors?.[0]?.extensions;
|
||||
|
||||
return isDefined(extensions)
|
||||
? (extensions as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(error) &&
|
||||
typeof error === 'object' &&
|
||||
'extensions' in error &&
|
||||
isDefined(error.extensions) &&
|
||||
typeof error.extensions === 'object'
|
||||
) {
|
||||
return error.extensions as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type ErrorLike } from '@apollo/client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
|
||||
|
||||
export const isGraphqlErrorOfType = (
|
||||
error: unknown,
|
||||
errorCode: string,
|
||||
): error is ErrorLike => {
|
||||
if (!isDefined(error) || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extensions = getGraphqlErrorExtensionsFromError(error);
|
||||
|
||||
return (
|
||||
(isDefined(extensions?.subCode) && extensions.subCode === errorCode) ||
|
||||
(isDefined(extensions?.code) && extensions.code === errorCode) ||
|
||||
('code' in error && error.code === errorCode)
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user