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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-31
@@ -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);
|
||||
});
|
||||
});
|
||||
-31
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user