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,8 +1,12 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconAlertCircle } from 'twenty-ui/display';
import { useContext } from 'react';
import { type AIChatError } from '@/ai/types/AIChatError';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
const StyledErrorContainer = styled.div`
align-items: center;
@@ -40,11 +44,14 @@ const StyledErrorMessage = styled.div`
`;
type AIChatErrorMessageProps = {
error: Error;
error: AIChatError;
};
export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
const { theme } = useContext(ThemeContext);
const errorMessage = CombinedGraphQLErrors.is(error)
? getErrorMessageFromApolloError(error)
: error.message;
return (
<StyledErrorContainer>
@@ -54,7 +61,7 @@ export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
<StyledErrorContent>
<StyledErrorTitle>{t`Failed to get response`}</StyledErrorTitle>
<StyledErrorMessage>
{error.message || t`An error occurred while processing your message`}
{errorMessage || t`An error occurred while processing your message`}
</StyledErrorMessage>
</StyledErrorContent>
</StyledErrorContainer>
@@ -1,19 +1,20 @@
import { AIChatApiKeyNotConfiguredMessage } from '@/ai/components/AIChatApiKeyNotConfiguredMessage';
import { AIChatCreditsExhaustedMessage } from '@/ai/components/AIChatCreditsExhaustedMessage';
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
import { type AIChatError } from '@/ai/types/AIChatError';
import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
type AIChatErrorRendererProps = {
error: Error;
error: AIChatError;
};
export const AIChatErrorRenderer = ({ error }: AIChatErrorRendererProps) => {
if (isBillingCreditsExhaustedError(error)) {
if (isGraphqlErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED)) {
return <AIChatCreditsExhaustedMessage />;
}
if (isApiKeyNotConfiguredError(error)) {
if (isGraphqlErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED)) {
return <AIChatApiKeyNotConfiguredMessage />;
}
@@ -6,6 +6,7 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { type AIChatError } from '@/ai/types/AIChatError';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -142,7 +143,7 @@ const StyledFilesContainer = styled.div`
type AIChatMessageProps = {
messageId: string;
isLastMessageStreaming?: boolean;
error?: Error | undefined;
error?: AIChatError | undefined;
};
export const AIChatMessage = ({
@@ -1,4 +1,5 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
@@ -11,9 +12,11 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isDefined } from 'twenty-shared/utils';
const StyledErrorContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
padding: ${themeCssVariables.spacing[3]};
width: 100%;
`;
@@ -0,0 +1,2 @@
export const AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME =
'agent-chat-restore-editor-content' as const;
@@ -5,9 +5,10 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extensions/placeholder';
import { useEditor } from '@tiptap/react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
@@ -21,6 +22,7 @@ import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSugges
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -134,6 +136,20 @@ export const useAIChatEditor = () => {
mentionStorage.searchMentionRecords = searchMentionRecords;
}
const handleRestoreEditorContent = useCallback(
(detail?: { content: string }) => {
if (isDefined(detail?.content)) {
editor?.commands.setContent(textToTiptapContent(detail.content));
}
},
[editor],
);
useListenToBrowserEvent<{ content: string }>({
eventName: AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME,
onBrowserEvent: handleRestoreEditorContent,
});
const handleSendAndClear = () => {
dispatchAgentChatSendMessageEvent();
editor?.commands.clearContent();
@@ -1,3 +1,4 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback, useState } from 'react';
@@ -7,6 +8,7 @@ import { v4 } from 'uuid';
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName';
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { SEND_CHAT_MESSAGE } from '@/ai/graphql/mutations/sendChatMessage';
@@ -15,6 +17,7 @@ import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
@@ -109,10 +112,15 @@ export const useAgentChat = (
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: { threadId },
});
const errorAtom = agentChatErrorComponentFamilyState.atomFamily({
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: { threadId },
});
const currentMessages = store.get(messagesAtom);
store.set(messagesAtom, [...currentMessages, optimisticUserMessage]);
store.set(errorAtom, null);
const fileIds = agentChatUploadedFiles.map((file) => file.fileId);
@@ -155,11 +163,17 @@ export const useAgentChat = (
return null;
});
} catch {
} catch (error) {
const restoredDraftKey =
draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ? threadId : draftKey;
setAgentChatInput(contentToSend);
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[draftKey]: contentToSend,
[restoredDraftKey]: contentToSend,
...(draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY
? { [AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '' }
: {}),
}));
const latestMessages = store.get(messagesAtom);
@@ -168,6 +182,23 @@ export const useAgentChat = (
messagesAtom,
latestMessages.filter((message) => message.id !== messageId),
);
store.set(
errorAtom,
CombinedGraphQLErrors.is(error) || error instanceof Error
? error
: new Error('An unexpected error occurred'),
);
dispatchBrowserEvent(AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME, {
content: contentToSend,
});
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
setCurrentAIChatThread(threadId);
}
setPendingThreadIdAfterFirstSend(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -1,8 +1,12 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { type AIChatError } from '@/ai/types/AIChatError';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const agentChatErrorComponentFamilyState =
createAtomComponentFamilyState<Error | null, { threadId: string | null }>({
createAtomComponentFamilyState<
AIChatError | null,
{ threadId: string | null }
>({
key: 'agentChatErrorComponentFamilyState',
defaultValue: null,
componentInstanceContext: AgentChatComponentInstanceContext,
@@ -0,0 +1,3 @@
import { type CombinedGraphQLErrors } from '@apollo/client/errors';
export type AIChatError = Error | CombinedGraphQLErrors;
@@ -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);
};
@@ -16,6 +16,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -102,10 +103,7 @@ export const VerifyEmailEffect = () => {
dedupeKey: 'email-verification-error-dedupe-key',
},
});
if (
CombinedGraphQLErrors.is(error) &&
error.errors[0].extensions?.subCode === 'EMAIL_ALREADY_VERIFIED'
) {
if (isGraphqlErrorOfType(error, 'EMAIL_ALREADY_VERIFIED')) {
navigate(AppPath.SignInUp);
}
@@ -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;
@@ -15,6 +15,7 @@ import {
type RenewApplicationTokenMutation,
type RenewApplicationTokenMutationVariables,
} from '~/generated-metadata/graphql';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE =
'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED';
@@ -22,10 +23,11 @@ const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE =
const hasApplicationRefreshTokenInvalidOrExpiredSubCode = (
errors: ReadonlyArray<GraphQLFormattedError>,
): boolean =>
errors.some(
(error) =>
error.extensions?.subCode ===
errors.some((error) =>
isGraphqlErrorOfType(
error,
APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE,
),
);
type UseRequestApplicationTokenRefreshArgs = {
@@ -21,6 +21,7 @@ import {
type AddQuerySubscriptionInput,
type RemoveQueryFromEventStreamInput,
} from '~/generated-metadata/graphql';
import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
export const SSEQuerySubscribeEffect = () => {
const store = useStore();
@@ -88,10 +89,14 @@ export const SSEQuerySubscribeEffect = () => {
}
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
const subCode = error.errors[0]?.extensions?.subCode;
const code = error.errors[0]?.extensions?.code;
const extensions = getGraphqlErrorExtensionsFromError(error);
if (isGracefullyHandledEventStreamError({ subCode, code })) {
if (
isGracefullyHandledEventStreamError({
subCode: extensions?.subCode,
code: extensions?.code,
})
) {
store.set(activeQueryListenersState.atom, []);
store.set(shouldDestroyEventStreamState.atom, true);
return;
@@ -20,6 +20,7 @@ import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type EventSubscription } from '~/generated-metadata/graphql';
import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
export const useTriggerEventStreamCreation = () => {
const store = useStore();
@@ -81,10 +82,16 @@ export const useTriggerEventStreamCreation = () => {
}>,
) => {
if (isDefined(value?.errors) && Array.isArray(value.errors)) {
const subCode = value.errors[0]?.extensions?.subCode;
const code = value.errors[0]?.extensions?.code;
const extensions = getGraphqlErrorExtensionsFromError(
value.errors[0],
);
if (!isGracefullyHandledEventStreamError({ subCode, code })) {
if (
!isGracefullyHandledEventStreamError({
subCode: extensions?.subCode,
code: extensions?.code,
})
) {
captureException(
new Error(
`SSE subscription error: ${value.errors[0]?.message}`,
@@ -137,10 +144,16 @@ export const useTriggerEventStreamCreation = () => {
try {
if (event === 'next') {
if (isDefined(result?.errors)) {
const subCode = result.errors[0]?.extensions?.subCode;
const code = result.errors[0]?.extensions?.code;
const extensions = getGraphqlErrorExtensionsFromError(
result.errors[0],
);
if (!isGracefullyHandledEventStreamError({ subCode, code })) {
if (
!isGracefullyHandledEventStreamError({
subCode: extensions?.subCode,
code: extensions?.code,
})
) {
for (const error of result.errors) {
captureException(error);
}
@@ -19,7 +19,6 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client';
import { useLazyQuery, useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
@@ -36,6 +35,7 @@ import {
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
type SettingsEnterpriseProps = {
isAdminPanelTab?: boolean;
@@ -202,11 +202,7 @@ export const SettingsEnterprise = ({
});
}
} catch (error) {
if (
CombinedGraphQLErrors.is(error) &&
error.errors?.[0]?.extensions?.subCode ===
'CONFIG_VARIABLES_IN_DB_DISABLED'
) {
if (isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED')) {
enqueueErrorSnackBar({
apolloError: error,
options: { duration: 10000 },
@@ -0,0 +1,76 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const createCombinedGraphQLError = (
errors: Array<{
message: string;
extensions?: Record<string, unknown>;
}>,
): CombinedGraphQLErrors => new CombinedGraphQLErrors({ errors, data: null });
describe('isGraphqlErrorOfType', () => {
it('matches a subCode from an Apollo GraphQL error', () => {
const error = createCombinedGraphQLError([
{
message: 'Credits exhausted',
extensions: {
code: 'FORBIDDEN',
subCode: 'BILLING_CREDITS_EXHAUSTED',
},
},
]);
expect(isGraphqlErrorOfType(error, 'BILLING_CREDITS_EXHAUSTED')).toBe(true);
});
it('matches the GraphQL code when no subCode is present', () => {
const error = createCombinedGraphQLError([
{
message: 'Unauthorized',
extensions: {
code: 'UNAUTHENTICATED',
},
},
]);
expect(isGraphqlErrorOfType(error, 'UNAUTHENTICATED')).toBe(true);
});
it('matches a plain error code property', () => {
const error = new Error('Stream failed') as Error & { code: string };
error.code = 'AGENT_EXECUTION_FAILED';
expect(isGraphqlErrorOfType(error, 'AGENT_EXECUTION_FAILED')).toBe(true);
});
it('matches a subCode from a plain GraphQLFormattedError object', () => {
const error = {
message: 'Refresh token expired',
extensions: {
subCode: 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
},
};
expect(
isGraphqlErrorOfType(
error,
'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
),
).toBe(true);
});
it('returns false when the error code does not match', () => {
const error = createCombinedGraphQLError([
{
message: 'Unauthorized',
extensions: {
code: 'UNAUTHENTICATED',
},
},
]);
expect(isGraphqlErrorOfType(error, 'FORBIDDEN')).toBe(false);
});
});
@@ -0,0 +1,26 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { isDefined } from 'twenty-shared/utils';
export const getGraphqlErrorExtensionsFromError = (
error: unknown,
): Record<string, unknown> | undefined => {
if (CombinedGraphQLErrors.is(error)) {
const extensions = error.errors?.[0]?.extensions;
return isDefined(extensions)
? (extensions as Record<string, unknown>)
: undefined;
}
if (
isDefined(error) &&
typeof error === 'object' &&
'extensions' in error &&
isDefined(error.extensions) &&
typeof error.extensions === 'object'
) {
return error.extensions as Record<string, unknown>;
}
return undefined;
};
@@ -0,0 +1,21 @@
import { type ErrorLike } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
export const isGraphqlErrorOfType = (
error: unknown,
errorCode: string,
): error is ErrorLike => {
if (!isDefined(error) || typeof error !== 'object') {
return false;
}
const extensions = getGraphqlErrorExtensionsFromError(error);
return (
(isDefined(extensions?.subCode) && extensions.subCode === errorCode) ||
(isDefined(extensions?.code) && extensions.code === errorCode) ||
('code' in error && error.code === errorCode)
);
};
@@ -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);
}
}
};
@@ -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,
@@ -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();
});
});
@@ -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,
@@ -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,
@@ -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(