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:
@@ -3,7 +3,6 @@ import {
|
||||
useLazyQuery,
|
||||
useMutation,
|
||||
} from '@apollo/client/react';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useCallback } from 'react';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
|
||||
@@ -67,6 +66,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
export const useAuth = () => {
|
||||
@@ -208,11 +208,7 @@ export const useAuth = () => {
|
||||
|
||||
return getLoginTokenResult.data.getLoginTokenFromCredentials;
|
||||
} catch (error) {
|
||||
// TODO: Get intellisense for graphql error extensions code (codegen?)
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
|
||||
) {
|
||||
if (isGraphqlErrorOfType(error, 'EMAIL_NOT_VERIFIED')) {
|
||||
setSearchParams({ email });
|
||||
setSignInUpStep(SignInUpStep.EmailVerification);
|
||||
throw error;
|
||||
@@ -334,9 +330,10 @@ export const useAuth = () => {
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.subCode ===
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED'
|
||||
isGraphqlErrorOfType(
|
||||
error,
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
|
||||
)
|
||||
) {
|
||||
handleSetLoginToken(loginToken);
|
||||
navigate(AppPath.SignInUp);
|
||||
@@ -344,9 +341,10 @@ export const useAuth = () => {
|
||||
}
|
||||
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.subCode ===
|
||||
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED'
|
||||
isGraphqlErrorOfType(
|
||||
error,
|
||||
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
|
||||
)
|
||||
) {
|
||||
handleSetLoginToken(loginToken);
|
||||
navigate(AppPath.SignInUp);
|
||||
@@ -399,10 +397,7 @@ export const useAuth = () => {
|
||||
setSignInUpStep(SignInUpStep.WorkspaceSelection);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
|
||||
) {
|
||||
if (isGraphqlErrorOfType(error, 'EMAIL_NOT_VERIFIED')) {
|
||||
setSearchParams({ email });
|
||||
setSignInUpStep(SignInUpStep.EmailVerification);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user