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:
+6
-37
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+40
@@ -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;
|
||||
};
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { APP_FILTER, HttpAdapterHost } from '@nestjs/core';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
|
||||
@@ -20,6 +20,7 @@ import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-acc
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { BillingWebhookModule } from 'src/engine/core-modules/billing-webhook/billing-webhook.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { BillingGraphqlApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-graphql-api-exception.filter';
|
||||
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
|
||||
import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module';
|
||||
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
|
||||
@@ -164,6 +165,12 @@ import { FileModule } from './file/file.module';
|
||||
DashboardModule,
|
||||
EventLogsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: BillingGraphqlApiExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { agentGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/ai-agent/utils/agent-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 {
|
||||
agentGraphqlApiExceptionHandler(error);
|
||||
throw new Error('Expected agentGraphqlApiExceptionHandler to throw');
|
||||
} catch (graphqlError) {
|
||||
return graphqlError as BaseGraphQLError;
|
||||
}
|
||||
};
|
||||
|
||||
describe('agentGraphqlApiExceptionHandler', () => {
|
||||
it('maps API key configuration failures to INTERNAL_SERVER_ERROR with a subCode', () => {
|
||||
const error = new AgentException(
|
||||
'No AI models are available',
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
|
||||
const graphqlError = catchGraphqlError(error);
|
||||
|
||||
expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
expect(graphqlError.extensions.subCode).toBe(
|
||||
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
|
||||
);
|
||||
expect(graphqlError.extensions.userFriendlyMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import {
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@@ -27,7 +28,7 @@ export const agentGraphqlApiExceptionHandler = (error: Error) => {
|
||||
case AgentExceptionCode.AGENT_EXECUTION_FAILED:
|
||||
case AgentExceptionCode.API_KEY_NOT_CONFIGURED:
|
||||
case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND:
|
||||
throw error;
|
||||
throw new InternalServerError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
@@ -114,6 +115,7 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
|
||||
MessagePruningService,
|
||||
StreamAgentChatJob,
|
||||
SystemPromptBuilderService,
|
||||
AgentGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [
|
||||
AgentChatService,
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
|
||||
import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
@@ -26,6 +27,7 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@UseInterceptors(AgentGraphqlApiExceptionInterceptor)
|
||||
export class AgentChatSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Float,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
|
||||
import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
|
||||
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
|
||||
@@ -58,6 +59,7 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.AI),
|
||||
)
|
||||
@UseInterceptors(AgentGraphqlApiExceptionInterceptor)
|
||||
@MetadataResolver(() => AgentChatThreadDTO)
|
||||
export class AgentChatResolver {
|
||||
constructor(
|
||||
|
||||
Reference in New Issue
Block a user