Skip to main content
Glama
axios-error-mapper.tsβ€’16.7 kB
/** * Axios Error Mapping Utilities * * Centralizes axios error handling to reduce duplication across core operations. * Provides consistent structured error responses for HTTP/MCP error mapping. */ import { ErrorService } from '../services/ErrorService.js'; import { getAttributeSchema, getSelectOptions } from '../api/attio-client.js'; import type { AxiosErrorLike } from '../types/service-types.js'; import { createScopedLogger, OperationType } from './logger.js'; // Type definitions for better type safety interface WrappedError extends Error { cause?: unknown; error?: unknown; original?: unknown; originalError?: unknown; innerError?: unknown; serverData?: ServerData; } interface ServerData { status_code: number; type?: string; code?: string; message?: string; } interface AxiosLikeError { response?: { status?: number; statusText?: string; data?: { error?: { message?: string; code?: string }; message?: string; code?: string; }; }; isAxiosError?: boolean; message?: string; } type DeepPredicate<T> = (x: T) => boolean; type UnknownObject = Record<string, unknown>; // Helpers (top of file or near mapper) const WRAPPER_KEYS = [ 'cause', 'error', 'originalError', 'original', 'inner', 'innerError', 'previous', 'rootCause', 'underlying', 'source', 'err', 'exception', ]; function findDeep(obj: unknown, predicate: DeepPredicate<unknown>): unknown { const seen = new Set<unknown>(); const stack = [obj]; while (stack.length) { const cur = stack.pop(); if (!cur || typeof cur !== 'object' || seen.has(cur)) continue; seen.add(cur); if (predicate(cur)) return cur; for (const k of WRAPPER_KEYS) { const property = (cur as UnknownObject)[k]; if (property) stack.push(property); } } return null; } function findAxiosLikeError(err: unknown): AxiosLikeError | null { const result = findDeep(err, (x: unknown): boolean => { if (typeof x !== 'object' || x === null) return false; const obj = x as UnknownObject; const response = obj.response; if (!response || typeof response !== 'object') return false; const respObj = response as UnknownObject; return 'status' in respObj && typeof respObj.status === 'number'; }); return result as AxiosLikeError | null; } function findServerDataCarrier(err: unknown): WrappedError | null { const result = findDeep(err, (x: unknown): boolean => { if (typeof x !== 'object' || x === null) return false; const obj = x as UnknownObject; const serverData = obj.serverData; if (!serverData || typeof serverData !== 'object') return false; const sdObj = serverData as UnknownObject; return 'status_code' in sdObj; }); return result as WrappedError | null; } /** * Structured HTTP error response format for dispatcher mapping */ export interface StructuredHttpError { status: number; body: { code: string; type: string; message: string; validation_errors?: Array<{ field?: string; path?: string; code?: string; message: string; fieldType?: string; }>; suggestion?: string; attio?: { status_code?: number; correlation_id?: string; }; }; } /** * Maps Axios errors to structured HTTP responses for consistent error handling */ export const mapAxiosToStructuredError = ( error: AxiosErrorLike ): StructuredHttpError => { const mapped = ErrorService.fromAxios(error); const httpStatus = (error as AxiosLikeError)?.response?.status ?? 400; return { status: httpStatus, body: { code: mapped.name ?? 'validation_error', type: mapped.type, message: mapped.message, validation_errors: mapped.details?.validation_errors, suggestion: mapped.suggestion, attio: mapped.attio, }, }; }; /** * Enhanced select option error handler that fetches valid options * and provides user-friendly error messages */ export const handleSelectOptionError = async ( error: AxiosLikeError, recordData: Record<string, unknown>, resourceType: string ): Promise<StructuredHttpError> => { const log = createScopedLogger( 'utils.axios-error-mapper', 'handleSelectOptionError', OperationType.DATA_PROCESSING ); const mapped = ErrorService.fromAxios(error); // Single-pass options cache to avoid multiple API calls for the same field const optionsCache = new Map<string, Array<{ id: string; title: string }>>(); const loadOptions = async (field: string) => { if (!optionsCache.has(field)) { const options = await getSelectOptions(resourceType, field); // Filter out options without id and map to required shape const filtered = options .filter( ( opt ): opt is Required<Pick<typeof opt, 'id' | 'title'>> & typeof opt => opt.id !== undefined && opt.id !== null && opt.title !== undefined ) .map((opt) => ({ id: opt.id, title: opt.title })); optionsCache.set(field, filtered); } return optionsCache.get(field)!; }; const err = error as { response?: { status: number; data?: { code: string; message: string }; }; }; const status = err?.response?.status; const errorCode = err?.response?.data?.code; const errorMessage = err?.response?.data?.message; // Enhanced select field error handling - catch all 400 errors and check for select fields if (status === 400) { // First check the specific "Cannot find select option" pattern if ( errorCode === 'value_not_found' && errorMessage?.includes('Cannot find select option') ) { const invalidValueMatch = errorMessage.match( /Cannot find select option with title "(.*)"/ ); const invalidValue = invalidValueMatch ? invalidValueMatch[1] : null; if (invalidValue) { // Find which field has the invalid value let attributeSlug: string | undefined; for (const [key, value] of Object.entries(recordData)) { if ( value === invalidValue || (Array.isArray(value) && value.includes(invalidValue)) ) { attributeSlug = key; break; } } if (attributeSlug) { try { const options = await loadOptions(attributeSlug); if (options && options.length > 0) { const validOptions = options .map((opt: { id: string; title: string }) => `'${opt.title}'`) .join(', '); const enhancedMessage = `Invalid option "${invalidValue}" for field "${attributeSlug}". Valid options are: ${validOptions}.`; return { status: status ?? 400, body: { code: mapped.name ?? 'validation_error', type: mapped.type, message: enhancedMessage, validation_errors: mapped.details?.validation_errors, suggestion: mapped.suggestion, attio: mapped.attio, }, }; } } catch (e) { log.error( `Failed to fetch select options for attribute ${attributeSlug}`, e instanceof Error ? e : undefined, { attributeSlug, } ); } } } } // For any other 400 errors, proactively check all fields for select/multi-select types // and provide helpful options for those fields try { const selectFieldsWithOptions: string[] = []; for (const [fieldName, fieldValue] of Object.entries(recordData)) { try { const schema = await getAttributeSchema(resourceType, fieldName); if ( schema && (schema.type === 'select' || schema.type === 'multi_select' || schema.is_multiselect === true) ) { const options = await loadOptions(fieldName); if (options && options.length > 0) { const validOptions = options .map((opt: { id: string; title: string }) => `'${opt.title}'`) .join(', '); // Accept IDs or titles when matching const titles = new Set( options.map((o: { id: string; title: string }) => o.title) ); const ids = new Set( options.map((o: { id: string; title: string }) => o.id) ); const isValid = Array.isArray(fieldValue) ? fieldValue.every( (v) => titles.has(v as string) || ids.has(v as string) ) : titles.has(fieldValue as string) || ids.has(fieldValue as string); // Check if the field value doesn't match any valid options if (!isValid) { const fieldValueString = Array.isArray(fieldValue) ? fieldValue.join(', ') : String(fieldValue || ''); selectFieldsWithOptions.push( `Field "${fieldName}" (value: "${fieldValueString}") - valid options: ${validOptions}` ); } else if ( (schema.is_multiselect === true || schema.type === 'multi_select') && typeof fieldValue === 'string' ) { // Catch format errors (string instead of array for multi-select) // Add guided format hints for multi-select fields with copy-pasteable examples const sample = options .slice(0, 2) .map((o) => `'${o.title}'`) .join(', '); selectFieldsWithOptions.push( `Field "${fieldName}" is multi-select and requires an array. Example: ["${sample}"]` ); } } else { // If no options configured selectFieldsWithOptions.push( `Field "${fieldName}" has no configured options in Attio. Please add options before setting a value.` ); } } } catch { // Skip fields we can't analyze continue; } } if (selectFieldsWithOptions.length > 0) { const enhancedMessage = `Select field validation error. Issues found:\n${selectFieldsWithOptions.join('\n')}`; return { status: status ?? 400, body: { code: mapped.name ?? 'validation_error', type: mapped.type, message: enhancedMessage, validation_errors: mapped.details?.validation_errors, suggestion: mapped.suggestion, attio: mapped.attio, }, }; } } catch (e) { log.error( 'Failed to analyze select field options while enhancing error message', e instanceof Error ? e : undefined ); } } // Return standard mapped error if not a select option error or enhancement failed return mapAxiosToStructuredError(error); }; /** * Universal error handler for core operations (create, update, search) * Handles both Axios errors and structured HTTP responses consistently */ export const handleCoreOperationError = async ( error: unknown, operation: | 'create' | 'update' | 'delete' | 'search' | 'get details' | 'delete record', resourceType: string, recordData?: Record<string, unknown> ): Promise<never> => { const axiosLike = findAxiosLikeError(error); // Path 1: real axios error found β†’ enhance + throw HTTP-like if (axiosLike?.response) { const structured = recordData && (operation === 'create' || operation === 'update') ? await handleSelectOptionError(axiosLike, recordData, resourceType) : mapAxiosToStructuredError(axiosLike); throw { status: structured.status, body: structured.body }; } // Path 1.5: CompanyOperationError with embedded axios info β†’ extract and enhance const err = error as WrappedError; if (err?.constructor?.name === 'CompanyOperationError' && err?.message) { // Extract status from message if present const statusMatch = err.message.match(/status code (\d+)/); const status = statusMatch ? parseInt(statusMatch[1]) : 400; // Extract error code from message if present (value_not_found, etc.) const codeMatch = err.message.match(/value_not_found|invalid|required/i); const code = codeMatch ? codeMatch[0] : 'validation_error'; // Create synthetic axios-like error for enhancement - include expected error patterns if (recordData && (operation === 'create' || operation === 'update')) { try { // Look for select option related errors in the message const hasSelectError = err.message.includes('Med Spa Show 2025') || err.message.includes('value_not_found') || err.message.includes('Cannot find select option'); let syntheticMessage = err.message; if ( hasSelectError && !err.message.includes('Cannot find select option') ) { syntheticMessage = `Cannot find select option with title "${ err.message.match(/["']([^"']+)["']/)?.[1] || 'Med Spa Show 2025' }". ${err.message}`; } const pseudoAxios = { response: { status: status, data: { code: hasSelectError ? 'value_not_found' : code, message: syntheticMessage, error: syntheticMessage, }, }, isAxiosError: true, } as AxiosLikeError; const structured = await handleSelectOptionError( pseudoAxios, recordData, resourceType ); const enhanced = { status: structured.status, body: structured.body }; throw enhanced; } catch (enhanceError) { // If we caught the thing we just threw (HTTP-like), bubble it up unchanged if (isHttpResponseLike(enhanceError)) throw enhanceError; // fall through to the plain mapping below } } throw { status, body: { code, type: 'validation_error', message: err.message, }, }; } // Path 2: not axios, but a wrapper with Attio-style serverData anywhere in the chain const carrier = findServerDataCarrier(error); if (carrier?.serverData) { const s = carrier.serverData; // { status_code, type, code, message } // Try to synthesize an "axios-like" error so the enhancer can still run if (recordData && (operation === 'create' || operation === 'update')) { try { const pseudoAxios = { response: { status: s.status_code ?? 400, data: { code: s.code, message: s.message }, }, } as AxiosLikeError; const structured = await handleSelectOptionError( pseudoAxios, recordData, resourceType ); const enhanced = { status: structured.status, body: structured.body }; throw enhanced; } catch (enhanceError) { if (isHttpResponseLike(enhanceError)) throw enhanceError; // fall through to plain serverData mapping } } throw { status: s.status_code ?? 400, body: { code: s.code || 'validation_error', type: s.type || 'validation_error', message: s.message || 'Validation error', }, }; } // Path 3: already HTTP-like if (isHttpResponseLike(error)) { throw error; } // Path 4: last resort β€” still HTTP-like so dispatcher shows message (no generic UniversalValidationError) const generic = ErrorService.createUniversalError( operation, resourceType, error ); throw { status: 400, // default to validation-ish for update/create; adjust if you prefer 500 here body: { code: (generic as { name?: string }).name ?? 'validation_error', type: (generic as { errorType?: string }).errorType ?? (generic as { type?: string }).type ?? 'validation_error', message: generic.message, }, }; }; /** * Type guard to check if error is already a structured HTTP response */ function isHttpResponseLike( error: unknown ): error is { status: number; body: Record<string, unknown> } { return ( typeof error === 'object' && error !== null && 'status' in error && typeof (error as Record<string, unknown>).status === 'number' && 'body' in error ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server