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,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%;
|
||||
`;
|
||||
|
||||
|
||||
+2
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
+5
-3
@@ -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 = {
|
||||
|
||||
+8
-3
@@ -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;
|
||||
|
||||
+19
-6
@@ -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)
|
||||
);
|
||||
};
|
||||
+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