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
@@ -14,6 +14,7 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(BillingException, Stripe.errors.StripeError)
@@ -41,42 +42,10 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
);
}
switch (exception.code) {
case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
404,
);
case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY:
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
return this.httpExceptionHandlerService.handleError(
exception,
response,
402,
);
case BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND:
default:
return this.httpExceptionHandlerService.handleError(
exception,
response,
500,
);
}
return this.httpExceptionHandlerService.handleError(
exception,
response,
getBillingExceptionStatusCode(exception),
);
}
}
@@ -0,0 +1,27 @@
/* @license Enterprise */
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type GqlContextType } from '@nestjs/graphql';
import Stripe from 'stripe';
import { BillingException } from 'src/engine/core-modules/billing/billing.exception';
import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
@Catch(BillingException, Stripe.errors.StripeError)
export class BillingGraphqlApiExceptionFilter implements ExceptionFilter {
catch(
exception: BillingException | Stripe.errors.StripeError,
host: ArgumentsHost,
) {
if (host.getType<GqlContextType>() !== 'graphql') {
throw exception;
}
return billingGraphqlApiExceptionHandler(exception);
}
}
@@ -0,0 +1,65 @@
/* @license Enterprise */
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
import {
ErrorCode,
type BaseGraphQLError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
const catchGraphqlError = (error: Error): BaseGraphQLError => {
try {
billingGraphqlApiExceptionHandler(error);
throw new Error('Expected billingGraphqlApiExceptionHandler to throw');
} catch (graphqlError) {
return graphqlError as BaseGraphQLError;
}
};
describe('billingGraphqlApiExceptionHandler', () => {
it('maps credits exhausted to a GraphQL error with the billing subCode', () => {
const error = new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.FORBIDDEN);
expect(graphqlError.extensions.subCode).toBe(
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
expect(graphqlError.extensions.userFriendlyMessage).toBeDefined();
});
it('maps billing not found errors to NOT_FOUND', () => {
const error = new BillingException(
'Billing product not found',
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND);
expect(graphqlError.extensions.subCode).toBe(
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
});
it('maps internal billing failures to INTERNAL_SERVER_ERROR', () => {
const error = new BillingException(
'Invalid price tiers',
BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
);
const graphqlError = catchGraphqlError(error);
expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
expect(graphqlError.extensions.subCode).toBe(
BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
);
});
});
@@ -0,0 +1,40 @@
/* @license Enterprise */
import { msg } from '@lingui/core/macro';
import Stripe from 'stripe';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util';
export const billingGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof Stripe.errors.StripeError) {
throw new InternalServerError(error.message, {
subCode: BillingExceptionCode.BILLING_STRIPE_ERROR,
userFriendlyMessage: msg`A payment processing error occurred.`,
});
}
if (error instanceof BillingException) {
switch (getBillingExceptionStatusCode(error)) {
case 404:
throw new NotFoundError(error);
case 400:
throw new UserInputError(error);
case 402:
throw new ForbiddenError(error);
case 500:
throw new InternalServerError(error);
}
}
throw error;
};
@@ -0,0 +1,47 @@
/* @license Enterprise */
import { assertUnreachable } from 'twenty-shared/utils';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
export const getBillingExceptionStatusCode = (
exception: BillingException,
): 400 | 402 | 404 | 500 => {
switch (exception.code) {
case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
return 404;
case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY:
return 400;
case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
return 402;
case BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND:
case BillingExceptionCode.BILLING_PRICE_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID:
case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND:
case BillingExceptionCode.BILLING_UNHANDLED_ERROR:
case BillingExceptionCode.BILLING_STRIPE_ERROR:
case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_INVALID:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID:
case BillingExceptionCode.BILLING_PRICE_INVALID_TIERS:
case BillingExceptionCode.BILLING_PRICE_INVALID:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND:
case BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND:
return 500;
default: {
return assertUnreachable(exception.code);
}
}
};