improvement(error-messages): make error extraction generalized abstraction (#1676)

* make error extraction generalized abstraction

* remove comments

* remove console logs
This commit is contained in:
Vikhyath Mondreti
2025-10-17 17:30:12 -07:00
committed by GitHub
parent 64eee587cd
commit c1725c1c4b
10 changed files with 446 additions and 60 deletions

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest'
import { ErrorExtractorId, type ErrorInfo, extractErrorMessage } from './error-extractors'
describe('Error Extractors', () => {
describe('extractErrorMessage', () => {
it('should extract GraphQL error messages', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
errors: [{ message: 'GraphQL validation error' }],
},
}
expect(extractErrorMessage(errorInfo)).toBe('GraphQL validation error')
})
it('should extract Twitter API error details', () => {
const errorInfo: ErrorInfo = {
status: 403,
data: {
errors: [{ detail: 'Rate limit exceeded' }],
},
}
expect(extractErrorMessage(errorInfo)).toBe('Rate limit exceeded')
})
it('should extract Telegram API description', () => {
const errorInfo: ErrorInfo = {
status: 403,
data: {
ok: false,
error_code: 403,
description: "Forbidden: bots can't send messages to bots",
},
}
expect(extractErrorMessage(errorInfo)).toBe("Forbidden: bots can't send messages to bots")
})
it('should extract standard message field', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
message: 'Invalid request parameters',
},
}
expect(extractErrorMessage(errorInfo)).toBe('Invalid request parameters')
})
it('should extract OAuth error_description', () => {
const errorInfo: ErrorInfo = {
status: 401,
data: {
error: 'invalid_grant',
error_description: 'The provided authorization grant is invalid',
},
}
expect(extractErrorMessage(errorInfo)).toBe('The provided authorization grant is invalid')
})
it('should extract SOAP fault strings', () => {
const errorInfo: ErrorInfo = {
status: 500,
data: {
fault: {
faultstring: 'SOAP processing error',
},
},
}
expect(extractErrorMessage(errorInfo)).toBe('SOAP processing error')
})
it('should extract nested error object messages', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
error: {
message: 'Resource not found',
},
},
}
expect(extractErrorMessage(errorInfo)).toBe('Resource not found')
})
it('should handle string error field', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
error: 'Bad request',
},
}
expect(extractErrorMessage(errorInfo)).toBe('Bad request')
})
it('should extract errors array with strings', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
errors: ['Email is required', 'Password is too short'],
},
}
expect(extractErrorMessage(errorInfo)).toBe('Email is required')
})
it('should use HTTP status text as fallback', () => {
const errorInfo: ErrorInfo = {
status: 404,
statusText: 'Not Found',
data: {},
}
expect(extractErrorMessage(errorInfo)).toBe('Not Found')
})
it('should use final fallback when no pattern matches', () => {
const errorInfo: ErrorInfo = {
status: 500,
data: {},
}
expect(extractErrorMessage(errorInfo)).toBe('Request failed with status 500')
})
it('should handle undefined errorInfo', () => {
expect(extractErrorMessage(undefined)).toBe('Request failed with status unknown')
})
it('should handle empty strings gracefully', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
message: '',
},
}
// Should skip empty message and use fallback
expect(extractErrorMessage(errorInfo)).toBe('Request failed with status 400')
})
})
describe('extractErrorMessage with explicit extractorId', () => {
it('should use specified extractor directly (deterministic)', () => {
const errorInfo: ErrorInfo = {
status: 403,
data: {
description: "Forbidden: bots can't send messages to bots",
message: 'Some other message',
},
}
// With explicit extractor ID, should use Telegram extractor
expect(extractErrorMessage(errorInfo, ErrorExtractorId.TELEGRAM_DESCRIPTION)).toBe(
"Forbidden: bots can't send messages to bots"
)
})
it('should use specified extractor even when other patterns match first', () => {
const errorInfo: ErrorInfo = {
status: 400,
data: {
errors: [{ message: 'GraphQL error' }], // This would match first normally
message: 'Standard message', // Explicitly request this one
},
}
// With explicit ID, should skip GraphQL and use standard message
expect(extractErrorMessage(errorInfo, ErrorExtractorId.STANDARD_MESSAGE)).toBe(
'Standard message'
)
})
it('should fallback when specified extractor does not find message', () => {
const errorInfo: ErrorInfo = {
status: 404,
data: {
someOtherField: 'value',
},
}
// Telegram extractor won't find anything, should fallback
expect(extractErrorMessage(errorInfo, ErrorExtractorId.TELEGRAM_DESCRIPTION)).toBe(
'Request failed with status 404'
)
})
it('should warn and fallback for non-existent extractor ID', () => {
const errorInfo: ErrorInfo = {
status: 500,
data: {
message: 'Error message',
},
}
// Non-existent extractor should fallback
expect(extractErrorMessage(errorInfo, 'non-existent-extractor')).toBe(
'Request failed with status 500'
)
})
})
})

View File

@@ -0,0 +1,175 @@
/**
* Error Extractor Registry
*
* This module provides a clean, config-based approach to extracting error messages
* from diverse API error response formats.
*
* ## Adding a new extractor
*
* 1. Add entry to ERROR_EXTRACTORS array below:
* ```typescript
* {
* id: 'stripe-errors',
* description: 'Stripe API error format',
* examples: ['Stripe API'],
* extract: (errorInfo) => errorInfo?.data?.error?.message
* }
* ```
*
* 2. Add the ID to ErrorExtractorId constant at the bottom of this file
*/
export interface ErrorInfo {
status?: number
statusText?: string
data?: any
}
export type ErrorExtractor = (errorInfo?: ErrorInfo) => string | null | undefined
export interface ErrorExtractorConfig {
/** Unique identifier for this extractor */
id: string
/** Human-readable description of what API/pattern this handles */
description: string
/** Example APIs that use this pattern */
examples?: string[]
/** The extraction function */
extract: ErrorExtractor
}
const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
{
id: 'graphql-errors',
description: 'GraphQL errors array with message field',
examples: ['Linear API', 'GitHub GraphQL'],
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.message,
},
{
id: 'twitter-errors',
description: 'X/Twitter API error detail field',
examples: ['Twitter/X API'],
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.detail,
},
{
id: 'details-array',
description: 'Generic details array with message',
examples: ['Various REST APIs'],
extract: (errorInfo) => errorInfo?.data?.details?.[0]?.message,
},
{
id: 'hunter-errors',
description: 'Hunter API error details',
examples: ['Hunter.io API'],
extract: (errorInfo) => errorInfo?.data?.errors?.[0]?.details,
},
{
id: 'errors-array-string',
description: 'Errors array containing strings or objects with messages',
examples: ['Various APIs with error arrays'],
extract: (errorInfo) => {
if (!Array.isArray(errorInfo?.data?.errors)) return undefined
const firstError = errorInfo.data.errors[0]
if (typeof firstError === 'string') return firstError
return firstError?.message
},
},
{
id: 'telegram-description',
description: 'Telegram Bot API description field',
examples: ['Telegram Bot API'],
extract: (errorInfo) => errorInfo?.data?.description,
},
{
id: 'standard-message',
description: 'Standard message field in error response',
examples: ['Notion', 'Discord', 'GitHub', 'Twilio', 'Slack'],
extract: (errorInfo) => errorInfo?.data?.message,
},
{
id: 'soap-fault',
description: 'SOAP/XML fault string patterns',
examples: ['SOAP APIs', 'Legacy XML services'],
extract: (errorInfo) => errorInfo?.data?.fault?.faultstring || errorInfo?.data?.faultstring,
},
{
id: 'oauth-error-description',
description: 'OAuth2 error_description field',
examples: ['Microsoft OAuth', 'Google OAuth', 'OAuth2 providers'],
extract: (errorInfo) => errorInfo?.data?.error_description,
},
{
id: 'nested-error-object',
description: 'Error field containing nested object or string',
examples: ['Airtable', 'Google APIs'],
extract: (errorInfo) => {
const error = errorInfo?.data?.error
if (!error) return undefined
if (typeof error === 'string') return error
if (typeof error === 'object') {
return error.message || JSON.stringify(error)
}
return undefined
},
},
{
id: 'http-status-text',
description: 'HTTP response status text fallback',
examples: ['Generic HTTP errors'],
extract: (errorInfo) => errorInfo?.statusText,
},
]
const EXTRACTOR_MAP = new Map<string, ErrorExtractorConfig>(ERROR_EXTRACTORS.map((e) => [e.id, e]))
export function extractErrorMessageWithId(
errorInfo: ErrorInfo | undefined,
extractorId: string
): string {
const extractor = EXTRACTOR_MAP.get(extractorId)
if (!extractor) {
return `Request failed with status ${errorInfo?.status || 'unknown'}`
}
try {
const message = extractor.extract(errorInfo)
if (message && message.trim()) {
return message
}
} catch (error) {}
return `Request failed with status ${errorInfo?.status || 'unknown'}`
}
export function extractErrorMessage(errorInfo?: ErrorInfo, extractorId?: string): string {
if (extractorId) {
return extractErrorMessageWithId(errorInfo, extractorId)
}
// Backwards compatibility
for (const extractor of ERROR_EXTRACTORS) {
try {
const message = extractor.extract(errorInfo)
if (message && message.trim()) {
return message
}
} catch (error) {}
}
return `Request failed with status ${errorInfo?.status || 'unknown'}`
}
export const ErrorExtractorId = {
GRAPHQL_ERRORS: 'graphql-errors',
TWITTER_ERRORS: 'twitter-errors',
DETAILS_ARRAY: 'details-array',
HUNTER_ERRORS: 'hunter-errors',
ERRORS_ARRAY_STRING: 'errors-array-string',
TELEGRAM_DESCRIPTION: 'telegram-description',
STANDARD_MESSAGE: 'standard-message',
SOAP_FAULT: 'soap-fault',
OAUTH_ERROR_DESCRIPTION: 'oauth-error-description',
NESTED_ERROR_OBJECT: 'nested-error-object',
HTTP_STATUS_TEXT: 'http-status-text',
} as const

View File

@@ -4,6 +4,8 @@ import { parseMcpToolId } from '@/lib/mcp/utils'
import { getBaseUrl } from '@/lib/urls/utils'
import { generateRequestId } from '@/lib/utils'
import type { ExecutionContext } from '@/executor/types'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types'
import {
formatRequestParams,
@@ -29,52 +31,12 @@ const MCP_SYSTEM_PARAMETERS = new Set([
'blockNameMapping',
])
// Extract a concise, meaningful error message from diverse API error shapes
function getDeepApiErrorMessage(errorInfo?: {
status?: number
statusText?: string
data?: any
}): string {
return (
// GraphQL errors (Linear API)
errorInfo?.data?.errors?.[0]?.message ||
// X/Twitter API specific pattern
errorInfo?.data?.errors?.[0]?.detail ||
// Generic details array
errorInfo?.data?.details?.[0]?.message ||
// Hunter API pattern
errorInfo?.data?.errors?.[0]?.details ||
// Direct errors array (when errors[0] is a string or simple object)
(Array.isArray(errorInfo?.data?.errors)
? typeof errorInfo.data.errors[0] === 'string'
? errorInfo.data.errors[0]
: errorInfo.data.errors[0]?.message
: undefined) ||
// Notion/Discord/GitHub/Twilio pattern
errorInfo?.data?.message ||
// SOAP/XML fault patterns
errorInfo?.data?.fault?.faultstring ||
errorInfo?.data?.faultstring ||
// Microsoft/OAuth error descriptions
errorInfo?.data?.error_description ||
// Airtable/Google fallback pattern
(typeof errorInfo?.data?.error === 'object'
? errorInfo?.data?.error?.message || JSON.stringify(errorInfo?.data?.error)
: errorInfo?.data?.error) ||
// HTTP status text fallback
errorInfo?.statusText ||
// Final fallback
`Request failed with status ${errorInfo?.status || 'unknown'}`
)
}
// Create an Error instance from errorInfo and attach useful context
function createTransformedErrorFromErrorInfo(errorInfo?: {
status?: number
statusText?: string
data?: any
}): Error {
const message = getDeepApiErrorMessage(errorInfo)
/**
* Create an Error instance from errorInfo and attach useful context
* Uses the error extractor registry to find the best error message
*/
function createTransformedErrorFromErrorInfo(errorInfo?: ErrorInfo, extractorId?: string): Error {
const message = extractErrorMessage(errorInfo, extractorId)
const transformed = new Error(message)
Object.assign(transformed, {
status: errorInfo?.status,
@@ -506,7 +468,7 @@ async function handleInternalRequest(
const { isError, errorInfo } = isErrorResponse(response, errorData)
if (isError) {
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo)
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo, tool.errorExtractor)
logger.error(`[${requestId}] Internal API error for ${toolId}:`, {
status: errorInfo?.status,
@@ -543,7 +505,7 @@ async function handleInternalRequest(
if (isError) {
// Handle error case
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo)
const errorToTransform = createTransformedErrorFromErrorInfo(errorInfo, tool.errorExtractor)
logger.error(`[${requestId}] Internal API error for ${toolId}:`, {
status: errorInfo?.status,

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramDeleteMessageParams,
TelegramDeleteMessageResponse,
@@ -13,6 +14,7 @@ export const telegramDeleteMessageTool: ToolConfig<
description:
'Delete messages in Telegram channels or chats through the Telegram Bot API. Requires the message ID of the message to delete.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -50,10 +52,16 @@ export const telegramDeleteMessageTool: ToolConfig<
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to delete message'
throw new Error(errorMessage)
}
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Message deleted successfully' : 'Failed to delete message',
message: 'Message deleted successfully',
data: {
ok: data.ok,
deleted: data.result,

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramMessage,
TelegramSendMessageParams,
@@ -15,6 +16,7 @@ export const telegramMessageTool: ToolConfig<
description:
'Send messages to Telegram channels or users through the Telegram Bot API. Enables direct communication and notifications with message tracking and chat confirmation.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -53,12 +55,18 @@ export const telegramMessageTool: ToolConfig<
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to send message'
throw new Error(errorMessage)
}
const result = data.result as TelegramMessage
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Message sent successfully' : 'Failed to send message',
message: 'Message sent successfully',
data: result,
},
}

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramMedia,
TelegramSendAnimationParams,
@@ -14,6 +15,7 @@ export const telegramSendAnimationTool: ToolConfig<
name: 'Telegram Send Animation',
description: 'Send animations (GIFs) to Telegram channels or users through the Telegram Bot API.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -66,11 +68,18 @@ export const telegramSendAnimationTool: ToolConfig<
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to send animation'
throw new Error(errorMessage)
}
const result = data.result as TelegramMedia
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Animation sent successfully' : 'Failed to send animation',
message: 'Animation sent successfully',
data: result,
},
}

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramAudio,
TelegramSendAudioParams,
@@ -12,6 +13,7 @@ export const telegramSendAudioTool: ToolConfig<TelegramSendAudioParams, Telegram
name: 'Telegram Send Audio',
description: 'Send audio files to Telegram channels or users through the Telegram Bot API.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -64,12 +66,18 @@ export const telegramSendAudioTool: ToolConfig<TelegramSendAudioParams, Telegram
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to send audio'
throw new Error(errorMessage)
}
const result = data.result as TelegramAudio
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Audio sent successfully' : 'Failed to send audio',
message: 'Audio sent successfully',
data: result,
},
}

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramPhoto,
TelegramSendPhotoParams,
@@ -12,6 +13,7 @@ export const telegramSendPhotoTool: ToolConfig<TelegramSendPhotoParams, Telegram
name: 'Telegram Send Photo',
description: 'Send photos to Telegram channels or users through the Telegram Bot API.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -64,12 +66,18 @@ export const telegramSendPhotoTool: ToolConfig<TelegramSendPhotoParams, Telegram
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to send photo'
throw new Error(errorMessage)
}
const result = data.result as TelegramPhoto
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Photo sent successfully' : 'Failed to send photo',
message: 'Photo sent successfully',
data: result,
},
}

View File

@@ -1,3 +1,4 @@
import { ErrorExtractorId } from '@/tools/error-extractors'
import type {
TelegramMedia,
TelegramSendMediaResponse,
@@ -12,6 +13,7 @@ export const telegramSendVideoTool: ToolConfig<TelegramSendVideoParams, Telegram
name: 'Telegram Send Video',
description: 'Send videos to Telegram channels or users through the Telegram Bot API.',
version: '1.0.0',
errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION,
params: {
botToken: {
@@ -64,12 +66,18 @@ export const telegramSendVideoTool: ToolConfig<TelegramSendVideoParams, Telegram
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
const errorMessage = data.description || data.error || 'Failed to send video'
throw new Error(errorMessage)
}
const result = data.result as TelegramMedia
return {
success: data.ok,
success: true,
output: {
message: data.ok ? 'Video sent successfully' : 'Failed to send video',
message: 'Video sent successfully',
data: result,
},
}

View File

@@ -78,6 +78,11 @@ export interface ToolConfig<P = any, R = any> {
// OAuth configuration for this tool (if it requires authentication)
oauth?: OAuthConfig
// Error extractor to use for this tool's error responses
// If specified, only this extractor will be used (deterministic)
// If not specified, will try all extractors in order (fallback)
errorExtractor?: string
// Request configuration
request: {
url: string | ((params: P) => string)