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
@@ -1,66 +0,0 @@
import { extractErrorCode } from '@/ai/utils/extractErrorCode';
describe('extractErrorCode', () => {
describe('direct error code', () => {
it('should extract code from error with direct code property', () => {
const error = { code: 'BILLING_CREDITS_EXHAUSTED', message: 'test' };
expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
});
it('should extract code from Error object with code property', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
});
});
describe('nested error structure', () => {
it('should extract code from nested error structure', () => {
const error = {
error: { code: 'BILLING_CREDITS_EXHAUSTED' },
};
expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
});
it('should extract code from deeply nested error structure', () => {
const error = {
data: {
error: { code: 'API_KEY_NOT_CONFIGURED' },
},
};
expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
});
});
describe('invalid inputs', () => {
it('should return undefined for null', () => {
expect(extractErrorCode(null)).toBeUndefined();
});
it('should return undefined for undefined', () => {
expect(extractErrorCode(undefined)).toBeUndefined();
});
it('should return undefined for error without code', () => {
const error = { message: 'test error' };
expect(extractErrorCode(error)).toBeUndefined();
});
it('should return undefined for error with non-string code', () => {
const error = { code: 123 };
expect(extractErrorCode(error)).toBeUndefined();
});
it('should return undefined for string input', () => {
expect(extractErrorCode('error string')).toBeUndefined();
});
it('should return undefined for number input', () => {
expect(extractErrorCode(42)).toBeUndefined();
});
it('should return undefined for empty object', () => {
expect(extractErrorCode({})).toBeUndefined();
});
});
});
@@ -1,42 +0,0 @@
import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
describe('extractErrorMessage', () => {
it('should return the string directly when error is a string', () => {
expect(extractErrorMessage('Something went wrong')).toBe(
'Something went wrong',
);
});
it('should extract message from object with message property', () => {
expect(extractErrorMessage({ message: 'Error occurred' })).toBe(
'Error occurred',
);
});
it('should extract message from nested error object', () => {
expect(extractErrorMessage({ error: { message: 'Nested error' } })).toBe(
'Nested error',
);
});
it('should extract message from deeply nested error object', () => {
expect(
extractErrorMessage({
data: { error: { message: 'Deep nested error' } },
}),
).toBe('Deep nested error');
});
it('should return fallback message for unknown error shapes', () => {
const result = extractErrorMessage(42);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('should return fallback message for null', () => {
const result = extractErrorMessage(null);
expect(typeof result).toBe('string');
});
});
@@ -1,60 +0,0 @@
import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
describe('isAIChatErrorOfType', () => {
describe('matching error codes', () => {
it('should return true when error code matches BILLING_CREDITS_EXHAUSTED', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(true);
});
it('should return true when error code matches API_KEY_NOT_CONFIGURED', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED),
).toBe(true);
});
});
describe('non-matching error codes', () => {
it('should return false when error code does not match', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'SOME_OTHER_ERROR';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
it('should return false when error has no code', () => {
const error = new Error('test');
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
});
describe('null and undefined handling', () => {
it('should return false for null error', () => {
expect(
isAIChatErrorOfType(null, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
it('should return false for undefined error', () => {
expect(
isAIChatErrorOfType(
undefined,
AIChatErrorCode.BILLING_CREDITS_EXHAUSTED,
),
).toBe(false);
});
});
});
@@ -1,31 +0,0 @@
import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
describe('isApiKeyNotConfiguredError', () => {
it('should return true for API key not configured error', () => {
const error = new Error('API key not set') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(isApiKeyNotConfiguredError(error)).toBe(true);
});
it('should return false for billing credits exhausted error', () => {
const error = new Error('Credits exhausted') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(isApiKeyNotConfiguredError(error)).toBe(false);
});
it('should return false for generic error', () => {
const error = new Error('Something went wrong');
expect(isApiKeyNotConfiguredError(error)).toBe(false);
});
it('should return false for null', () => {
expect(isApiKeyNotConfiguredError(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isApiKeyNotConfiguredError(undefined)).toBe(false);
});
});
@@ -1,31 +0,0 @@
import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
describe('isBillingCreditsExhaustedError', () => {
it('should return true for billing credits exhausted error', () => {
const error = new Error('Credits exhausted') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(isBillingCreditsExhaustedError(error)).toBe(true);
});
it('should return false for API key not configured error', () => {
const error = new Error('API key not set') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(isBillingCreditsExhaustedError(error)).toBe(false);
});
it('should return false for generic error', () => {
const error = new Error('Something went wrong');
expect(isBillingCreditsExhaustedError(error)).toBe(false);
});
it('should return false for null', () => {
expect(isBillingCreditsExhaustedError(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isBillingCreditsExhaustedError(undefined)).toBe(false);
});
});
@@ -3,6 +3,3 @@ export const AIChatErrorCode = {
BILLING_CREDITS_EXHAUSTED: 'BILLING_CREDITS_EXHAUSTED',
API_KEY_NOT_CONFIGURED: 'API_KEY_NOT_CONFIGURED',
} as const;
export type AIChatErrorCodeType =
(typeof AIChatErrorCode)[keyof typeof AIChatErrorCode];
@@ -1,53 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
// Type guard for error objects with a code property
const isErrorWithCode = (
error: unknown,
): error is { code: string; message?: string } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string'
);
};
// Type guard for nested error structures (e.g., { error: { code: '...' } })
const isNestedErrorWithCode = (
error: unknown,
): error is { error: { code: string } } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'error' in error &&
isErrorWithCode((error as { error: unknown }).error)
);
};
// Type guard for deeply nested error structures (e.g., { data: { error: { code: '...' } } })
const isDeepNestedErrorWithCode = (
error: unknown,
): error is { data: { error: { code: string } } } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'data' in error &&
isNestedErrorWithCode((error as { data: unknown }).data)
);
};
export const extractErrorCode = (error: unknown): string | undefined => {
if (isErrorWithCode(error)) {
return error.code;
}
if (isNestedErrorWithCode(error)) {
return error.error.code;
}
if (isDeepNestedErrorWithCode(error)) {
return error.data.error.code;
}
return undefined;
};
@@ -1,66 +0,0 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
const isObjectWithMessage = (error: unknown): error is { message: string } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
);
};
const isErrorWithNestedError = (
error: unknown,
): error is {
error: { message: string };
} => {
return (
isDefined(error) &&
typeof error === 'object' &&
'error' in error &&
isDefined(error.error) &&
typeof error.error === 'object' &&
'message' in error.error &&
typeof error.error.message === 'string'
);
};
const isDeepNestedError = (
error: unknown,
): error is {
data: { error: { message: string } };
} => {
return (
isDefined(error) &&
typeof error === 'object' &&
'data' in error &&
isDefined(error.data) &&
typeof error.data === 'object' &&
'error' in error.data &&
isDefined(error.data.error) &&
typeof error.data.error === 'object' &&
'message' in error.data.error &&
typeof error.data.error.message === 'string'
);
};
export const extractErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
if (isObjectWithMessage(error)) {
return error.message;
}
if (isErrorWithNestedError(error)) {
return error.error.message;
}
if (isDeepNestedError(error)) {
return error.data.error.message;
}
return t`An unexpected error occurred`;
};
@@ -1,15 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type AIChatErrorCodeType } from '@/ai/utils/aiChatErrorCode';
import { extractErrorCode } from '@/ai/utils/extractErrorCode';
export const isAIChatErrorOfType = (
error: Error | null | undefined,
errorCode: AIChatErrorCodeType,
): boolean => {
if (!isDefined(error)) {
return false;
}
return extractErrorCode(error) === errorCode;
};
@@ -1,8 +0,0 @@
import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
export const isApiKeyNotConfiguredError = (
error: Error | null | undefined,
): boolean => {
return isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED);
};
@@ -1,8 +0,0 @@
import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
export const isBillingCreditsExhaustedError = (
error: Error | null | undefined,
): boolean => {
return isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED);
};