Skip to content

Commit c1725c1

Browse files
improvement(error-messages): make error extraction generalized abstraction (#1676)
* make error extraction generalized abstraction * remove comments * remove console logs
1 parent 64eee58 commit c1725c1

File tree

10 files changed

+446
-60
lines changed

10 files changed

+446
-60
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { ErrorExtractorId, type ErrorInfo, extractErrorMessage } from './error-extractors'
3+
4+
describe('Error Extractors', () => {
5+
describe('extractErrorMessage', () => {
6+
it('should extract GraphQL error messages', () => {
7+
const errorInfo: ErrorInfo = {
8+
status: 400,
9+
data: {
10+
errors: [{ message: 'GraphQL validation error' }],
11+
},
12+
}
13+
expect(extractErrorMessage(errorInfo)).toBe('GraphQL validation error')
14+
})
15+
16+
it('should extract Twitter API error details', () => {
17+
const errorInfo: ErrorInfo = {
18+
status: 403,
19+
data: {
20+
errors: [{ detail: 'Rate limit exceeded' }],
21+
},
22+
}
23+
expect(extractErrorMessage(errorInfo)).toBe('Rate limit exceeded')
24+
})
25+
26+
it('should extract Telegram API description', () => {
27+
const errorInfo: ErrorInfo = {
28+
status: 403,
29+
data: {
30+
ok: false,
31+
error_code: 403,
32+
description: "Forbidden: bots can't send messages to bots",
33+
},
34+
}
35+
expect(extractErrorMessage(errorInfo)).toBe("Forbidden: bots can't send messages to bots")
36+
})
37+
38+
it('should extract standard message field', () => {
39+
const errorInfo: ErrorInfo = {
40+
status: 400,
41+
data: {
42+
message: 'Invalid request parameters',
43+
},
44+
}
45+
expect(extractErrorMessage(errorInfo)).toBe('Invalid request parameters')
46+
})
47+
48+
it('should extract OAuth error_description', () => {
49+
const errorInfo: ErrorInfo = {
50+
status: 401,
51+
data: {
52+
error: 'invalid_grant',
53+
error_description: 'The provided authorization grant is invalid',
54+
},
55+
}
56+
expect(extractErrorMessage(errorInfo)).toBe('The provided authorization grant is invalid')
57+
})
58+
59+
it('should extract SOAP fault strings', () => {
60+
const errorInfo: ErrorInfo = {
61+
status: 500,
62+
data: {
63+
fault: {
64+
faultstring: 'SOAP processing error',
65+
},
66+
},
67+
}
68+
expect(extractErrorMessage(errorInfo)).toBe('SOAP processing error')
69+
})
70+
71+
it('should extract nested error object messages', () => {
72+
const errorInfo: ErrorInfo = {
73+
status: 400,
74+
data: {
75+
error: {
76+
message: 'Resource not found',
77+
},
78+
},
79+
}
80+
expect(extractErrorMessage(errorInfo)).toBe('Resource not found')
81+
})
82+
83+
it('should handle string error field', () => {
84+
const errorInfo: ErrorInfo = {
85+
status: 400,
86+
data: {
87+
error: 'Bad request',
88+
},
89+
}
90+
expect(extractErrorMessage(errorInfo)).toBe('Bad request')
91+
})
92+
93+
it('should extract errors array with strings', () => {
94+
const errorInfo: ErrorInfo = {
95+
status: 400,
96+
data: {
97+
errors: ['Email is required', 'Password is too short'],
98+
},
99+
}
100+
expect(extractErrorMessage(errorInfo)).toBe('Email is required')
101+
})
102+
103+
it('should use HTTP status text as fallback', () => {
104+
const errorInfo: ErrorInfo = {
105+
status: 404,
106+
statusText: 'Not Found',
107+
data: {},
108+
}
109+
expect(extractErrorMessage(errorInfo)).toBe('Not Found')
110+
})
111+
112+
it('should use final fallback when no pattern matches', () => {
113+
const errorInfo: ErrorInfo = {
114+
status: 500,
115+
data: {},
116+
}
117+
expect(extractErrorMessage(errorInfo)).toBe('Request failed with status 500')
118+
})
119+
120+
it('should handle undefined errorInfo', () => {
121+
expect(extractErrorMessage(undefined)).toBe('Request failed with status unknown')
122+
})
123+
124+
it('should handle empty strings gracefully', () => {
125+
const errorInfo: ErrorInfo = {
126+
status: 400,
127+
data: {
128+
message: '',
129+
},
130+
}
131+
// Should skip empty message and use fallback
132+
expect(extractErrorMessage(errorInfo)).toBe('Request failed with status 400')
133+
})
134+
})
135+
136+
describe('extractErrorMessage with explicit extractorId', () => {
137+
it('should use specified extractor directly (deterministic)', () => {
138+
const errorInfo: ErrorInfo = {
139+
status: 403,
140+
data: {
141+
description: "Forbidden: bots can't send messages to bots",
142+
message: 'Some other message',
143+
},
144+
}
145+
146+
// With explicit extractor ID, should use Telegram extractor
147+
expect(extractErrorMessage(errorInfo, ErrorExtractorId.TELEGRAM_DESCRIPTION)).toBe(
148+
"Forbidden: bots can't send messages to bots"
149+
)
150+
})
151+
152+
it('should use specified extractor even when other patterns match first', () => {
153+
const errorInfo: ErrorInfo = {
154+
status: 400,
155+
data: {
156+
errors: [{ message: 'GraphQL error' }], // This would match first normally
157+
message: 'Standard message', // Explicitly request this one
158+
},
159+
}
160+
161+
// With explicit ID, should skip GraphQL and use standard message
162+
expect(extractErrorMessage(errorInfo, ErrorExtractorId.STANDARD_MESSAGE)).toBe(
163+
'Standard message'
164+
)
165+
})
166+
167+
it('should fallback when specified extractor does not find message', () => {
168+
const errorInfo: ErrorInfo = {
169+
status: 404,
170+
data: {
171+
someOtherField: 'value',
172+
},
173+
}
174+
175+
// Telegram extractor won't find anything, should fallback
176+
expect(extractErrorMessage(errorInfo, ErrorExtractorId.TELEGRAM_DESCRIPTION)).toBe(
177+
'Request failed with status 404'
178+
)
179+
})
180+
181+
it('should warn and fallback for non-existent extractor ID', () => {
182+
const errorInfo: ErrorInfo = {
183+
status: 500,
184+
data: {
185+
message: 'Error message',
186+
},
187+
}
188+
189+
// Non-existent extractor should fallback
190+
expect(extractErrorMessage(errorInfo, 'non-existent-extractor')).toBe(
191+
'Request failed with status 500'
192+
)
193+
})
194+
})
195+
})

apps/sim/tools/error-extractors.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Error Extractor Registry
3+
*
4+
* This module provides a clean, config-based approach to extracting error messages
5+
* from diverse API error response formats.
6+
*
7+
* ## Adding a new extractor
8+
*
9+
* 1. Add entry to ERROR_EXTRACTORS array below:
10+
* ```typescript
11+
* {
12+
* id: 'stripe-errors',
13+
* description: 'Stripe API error format',
14+
* examples: ['Stripe API'],
15+
* extract: (errorInfo) => errorInfo?.data?.error?.message
16+
* }
17+
* ```
18+
*
19+
* 2. Add the ID to ErrorExtractorId constant at the bottom of this file
20+
*/
21+
22+
export interface ErrorInfo {
23+
status?: number
24+
statusText?: string
25+
data?: any
26+
}
27+
28+
export type ErrorExtractor = (errorInfo?: ErrorInfo) => string | null | undefined
29+
30+
export interface ErrorExtractorConfig {
31+
/** Unique identifier for this extractor */
32+
id: string
33+
/** Human-readable description of what API/pattern this handles */
34+
description: string
35+
/** Example APIs that use this pattern */
36+
examples?: string[]
37+
/** The extraction function */
38+
extract: ErrorExtractor
39+
}
40+
41+
const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
42+
{
43+
id: 'graphql-errors',
44+
description: 'GraphQL errors array with message field',
45+
examples: ['Linear API', 'GitHub GraphQL'],
46+
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.message,
47+
},
48+
{
49+
id: 'twitter-errors',
50+
description: 'X/Twitter API error detail field',
51+
examples: ['Twitter/X API'],
52+
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.detail,
53+
},
54+
{
55+
id: 'details-array',
56+
description: 'Generic details array with message',
57+
examples: ['Various REST APIs'],
58+
extract: (errorInfo) => errorInfo?.data?.details?.[0]?.message,
59+
},
60+
{
61+
id: 'hunter-errors',
62+
description: 'Hunter API error details',
63+
examples: ['Hunter.io API'],
64+
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.details,
65+
},
66+
{
67+
id: 'errors-array-string',
68+
description: 'Errors array containing strings or objects with messages',
69+
examples: ['Various APIs with error arrays'],
70+
extract: (errorInfo) => {
71+
if (!Array.isArray(errorInfo?.data?.errors)) return undefined
72+
const firstError = errorInfo.data.errors[0]
73+
if (typeof firstError === 'string') return firstError
74+
return firstError?.message
75+
},
76+
},
77+
{
78+
id: 'telegram-description',
79+
description: 'Telegram Bot API description field',
80+
examples: ['Telegram Bot API'],
81+
extract: (errorInfo) => errorInfo?.data?.description,
82+
},
83+
{
84+
id: 'standard-message',
85+
description: 'Standard message field in error response',
86+
examples: ['Notion', 'Discord', 'GitHub', 'Twilio', 'Slack'],
87+
extract: (errorInfo) => errorInfo?.data?.message,
88+
},
89+
{
90+
id: 'soap-fault',
91+
description: 'SOAP/XML fault string patterns',
92+
examples: ['SOAP APIs', 'Legacy XML services'],
93+
extract: (errorInfo) => errorInfo?.data?.fault?.faultstring || errorInfo?.data?.faultstring,
94+
},
95+
{
96+
id: 'oauth-error-description',
97+
description: 'OAuth2 error_description field',
98+
examples: ['Microsoft OAuth', 'Google OAuth', 'OAuth2 providers'],
99+
extract: (errorInfo) => errorInfo?.data?.error_description,
100+
},
101+
{
102+
id: 'nested-error-object',
103+
description: 'Error field containing nested object or string',
104+
examples: ['Airtable', 'Google APIs'],
105+
extract: (errorInfo) => {
106+
const error = errorInfo?.data?.error
107+
if (!error) return undefined
108+
if (typeof error === 'string') return error
109+
if (typeof error === 'object') {
110+
return error.message || JSON.stringify(error)
111+
}
112+
return undefined
113+
},
114+
},
115+
{
116+
id: 'http-status-text',
117+
description: 'HTTP response status text fallback',
118+
examples: ['Generic HTTP errors'],
119+
extract: (errorInfo) => errorInfo?.statusText,
120+
},
121+
]
122+
123+
const EXTRACTOR_MAP = new Map<string, ErrorExtractorConfig>(ERROR_EXTRACTORS.map((e) => [e.id, e]))
124+
125+
export function extractErrorMessageWithId(
126+
errorInfo: ErrorInfo | undefined,
127+
extractorId: string
128+
): string {
129+
const extractor = EXTRACTOR_MAP.get(extractorId)
130+
131+
if (!extractor) {
132+
return `Request failed with status ${errorInfo?.status || 'unknown'}`
133+
}
134+
135+
try {
136+
const message = extractor.extract(errorInfo)
137+
if (message && message.trim()) {
138+
return message
139+
}
140+
} catch (error) {}
141+
142+
return `Request failed with status ${errorInfo?.status || 'unknown'}`
143+
}
144+
145+
export function extractErrorMessage(errorInfo?: ErrorInfo, extractorId?: string): string {
146+
if (extractorId) {
147+
return extractErrorMessageWithId(errorInfo, extractorId)
148+
}
149+
150+
// Backwards compatibility
151+
for (const extractor of ERROR_EXTRACTORS) {
152+
try {
153+
const message = extractor.extract(errorInfo)
154+
if (message && message.trim()) {
155+
return message
156+
}
157+
} catch (error) {}
158+
}
159+
160+
return `Request failed with status ${errorInfo?.status || 'unknown'}`
161+
}
162+
163+
export const ErrorExtractorId = {
164+
GRAPHQL_ERRORS: 'graphql-errors',
165+
TWITTER_ERRORS: 'twitter-errors',
166+
DETAILS_ARRAY: 'details-array',
167+
HUNTER_ERRORS: 'hunter-errors',
168+
ERRORS_ARRAY_STRING: 'errors-array-string',
169+
TELEGRAM_DESCRIPTION: 'telegram-description',
170+
STANDARD_MESSAGE: 'standard-message',
171+
SOAP_FAULT: 'soap-fault',
172+
OAUTH_ERROR_DESCRIPTION: 'oauth-error-description',
173+
NESTED_ERROR_OBJECT: 'nested-error-object',
174+
HTTP_STATUS_TEXT: 'http-status-text',
175+
} as const

0 commit comments

Comments
 (0)