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:
Félix Malfait
2026-04-14 18:31:59 +02:00
committed by GitHub
parent 3463ee5dc4
commit 307b6c94de
40 changed files with 489 additions and 475 deletions
@@ -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)
);
};