Skip to main content
Glama
enhanced-api-errors.tsβ€’18 kB
/** * Enhanced API Error System for Consolidated Issues #415, #416, #417 * * This module provides comprehensive error messaging with contextual information * to address: * - Issue #415: Poor error message quality and user experience * - Issue #416: Misleading "Invalid format" vs "Not found" confusion * - Issue #417: Task-specific error guidance and field mapping help */ import { AttioApiError } from '@errors/api-errors.js'; import type { AttributeMetadata } from '@services/utils/attribute-metadata.js'; import { isValidUUID } from '@utils/validation/uuid-validation.js'; /** * Enhanced error context interface providing rich information for better UX */ export interface EnhancedApiErrorContext { /** Field name that caused the error */ field?: string; /** Type information for the problematic field */ fieldType?: string; /** * Attio field configuration snapshot for deep diagnostics. * @remarks Commonly includes api_slug, field_type, title, and config.select options. */ fieldMetadata?: AttributeMetadata; /** Valid values for select fields */ validValues?: string[]; /** Suggested field names for typos */ suggestedFields?: string[]; /** Resource type being operated on */ resourceType?: string; /** Operation being performed (create, update, get, etc.) */ operation?: string; /** Whether the field is read-only */ isReadOnly?: boolean; /** Documentation hint or command suggestion */ documentationHint?: string; /** Record ID for "not found" vs "invalid format" distinction */ recordId?: string; /** HTTP status code for precise error categorization */ httpStatus?: number; /** Whether this error is retryable */ retryable?: boolean; /** Original error for debugging */ originalError?: Error; /** Server response data for debugging */ serverData?: Record<string, unknown>; } /** @deprecated Use EnhancedApiErrorContext instead */ export type ErrorContext = EnhancedApiErrorContext; /** * Enhanced API Error class that provides contextual error messages * * This class extends the base AttioApiError with rich context information * to provide actionable error messages that guide users toward solutions. */ export class EnhancedApiError extends AttioApiError { constructor( message: string, statusCode: number, endpoint: string, method: string, public readonly context?: EnhancedApiErrorContext ) { super(message, statusCode, endpoint, method); this.name = 'EnhancedApiError'; Object.setPrototypeOf(this, EnhancedApiError.prototype); } /** * Check if this is a user error (400-level) */ isUserError(): boolean { return this.statusCode >= 400 && this.statusCode < 500; } /** * Get error category for classification */ getErrorCategory(): 'user' | 'system' | 'network' | 'auth' | 'unknown' { if ([401, 403].includes(this.statusCode)) return 'auth'; if ([400, 404, 422].includes(this.statusCode)) return 'user'; if ([500, 502, 503, 504].includes(this.statusCode)) return 'system'; if ([429, 408].includes(this.statusCode)) return 'network'; return 'unknown'; } /** * Generate a contextual error message with actionable guidance * * This method analyzes the error context and constructs helpful messages * that address the specific issues identified in the consolidated issues. */ getContextualMessage(): string { let msg = this.message; // Issue #416: Handle "Not Found" vs "Invalid Format" confusion const notFoundMessage = this.getNotFoundErrorMessage(); if (notFoundMessage) { return notFoundMessage; } // Apply various enhancements to the message msg += this.getTaskErrorMessage(); msg += this.getFieldValidationMessage(); msg += this.getFieldSuggestions(); msg += this.getGeneralGuidanceMessage(); return msg; } /** * Generate "Record not found" error message for Issue #416 */ private getNotFoundErrorMessage(): string | null { if (this.context?.httpStatus === 404 && this.context?.recordId) { let msg = `Record not found: No ${this.context.resourceType} with ID '${this.context.recordId}' exists.`; if (this.context.documentationHint) { msg += ` ${this.context.documentationHint}`; } return msg; } return null; } /** * Generate field suggestions for typos */ private getFieldSuggestions(): string { if ( this.context?.suggestedFields && this.context.suggestedFields.length > 0 ) { return `\n\nDid you mean: ${this.context.suggestedFields.join(', ')}?`; } return ''; } /** * Generate task-specific error guidance for Issue #417 */ private getTaskErrorMessage(): string { if (this.context?.resourceType === 'tasks' && this.context?.field) { return this.getTaskSpecificGuidance(); } return ''; } /** * Generate field validation error messages for Issue #415 */ private getFieldValidationMessage(): string { let msg = ''; if (this.context?.field && this.context.fieldType) { msg += ` Field '${this.context.field}' expects values of type '${this.context.fieldType}'.`; } // Enhanced select field validation errors if (this.context?.validValues?.length) { msg += ` Valid options for '${ this.context.field }' are: [${this.context.validValues.join(', ')}].`; } // Field name suggestions for typos if (this.context?.suggestedFields?.length) { msg += ` Did you mean: ${this.context.suggestedFields.join(', ')}?`; } // Read-only field guidance if (this.context?.isReadOnly && this.context?.field) { msg += ` Field '${this.context.field}' is read-only and cannot be modified. This is a system-managed field.`; if (this.context.resourceType) { msg += ` Use get-attributes ${this.context.resourceType} --categories writable to see updatable fields.`; } } return msg; } /** * Generate general guidance messages */ private getGeneralGuidanceMessage(): string { let msg = ''; // General documentation hints (avoid duplicating for 404 errors) // Only skip if it's a 404 error (already handled in getNotFoundErrorMessage) if (this.context?.documentationHint && this.context.httpStatus !== 404) { msg += ` ${this.context.documentationHint}`; } // Retry guidance for temporary issues if (this.context?.retryable) { msg += ' This error may be temporary - please try again in a moment.'; } return msg; } /** * Generate task-specific error guidance * * Addresses Issue #417 by providing clear guidance for task field mappings * and common task-related errors. */ private getTaskSpecificGuidance(): string { const field = this.context?.field; if (!field) return ''; // Task field mapping guidance const taskFieldMappings: Record<string, string> = { title: ' For tasks, use "content" instead of "title" for the main task text.', name: ' For tasks, use "content" instead of "name" for the main task text.', description: ' For tasks, use "content" instead of "description" for the main task text.', assignee: ' For tasks, use "assignee_id" with a workspace member ID, not "assignee".', due: ' For tasks, use "due_date" with ISO date format (YYYY-MM-DD), not "due".', record: ' For tasks, use "record_id" to link to a specific record, not "record".', }; const guidance = taskFieldMappings[field.toLowerCase()]; if (guidance) { return guidance; } // General task guidance return ` For tasks, valid fields are: content, status, due_date, assignee_id, record_id. Check task API documentation for field requirements.`; } /** * Check if this error is likely retryable */ isRetryable(): boolean { if (this.context?.retryable !== undefined) { return this.context.retryable; } // 429 (rate limit), 5xx errors are generally retryable const retryableStatuses = [429, 500, 502, 503, 504]; return retryableStatuses.includes(this.statusCode); } } /** * Factory function to create enhanced API errors with proper context */ export function createEnhancedApiError( message: string, statusCode: number, endpoint: string, method: string, context?: Partial<EnhancedApiErrorContext> ): EnhancedApiError { return new EnhancedApiError(message, statusCode, endpoint, method, context); } /** * Enhanced error templates for common scenarios * * These templates provide consistent, helpful error messages across the application */ export const ErrorTemplates = { /** * Issue #415: Invalid select option template */ INVALID_SELECT_OPTION: ( field: string, value: string, validOptions: string[], resourceType?: string ) => createEnhancedApiError( `Invalid value '${value}' for field '${field}'`, 400, resourceType ? `/objects/${resourceType}` : '/unknown', 'POST', { field, fieldType: 'select', validValues: validOptions, resourceType, documentationHint: `Use get-attributes${ resourceType ? ` ${resourceType}` : '' } to see all available values.`, } ), /** * Issue #415: Read-only field template */ READ_ONLY_FIELD: (field: string, resourceType: string) => createEnhancedApiError( `Field '${field}' cannot be modified`, 400, `/objects/${resourceType}`, 'POST', { field, resourceType, isReadOnly: true, documentationHint: `Use get-attributes ${resourceType} --categories writable to see updatable fields.`, } ), /** * Issue #415: Unknown field template */ UNKNOWN_FIELD: (field: string, suggestions: string[], resourceType: string) => createEnhancedApiError( `Unknown field '${field}' for resource type '${resourceType}'`, 400, `/objects/${resourceType}`, 'POST', { field, suggestedFields: suggestions, resourceType, documentationHint: `Use get-attributes ${resourceType} to see all available fields with their correct names.`, } ), /** * Issue #416: Record not found template (vs invalid format) */ RECORD_NOT_FOUND: (recordId: string, resourceType: string) => createEnhancedApiError( `Record not found`, 404, `/objects/${resourceType}/${recordId}`, 'GET', { recordId, resourceType, httpStatus: 404, documentationHint: `Use search-records to find valid ${resourceType} IDs.`, } ), /** * Issue #416: Invalid UUID format template (vs not found) */ INVALID_UUID_FORMAT: (recordId: string, resourceType: string) => createEnhancedApiError( `Invalid record identifier format: '${recordId}'`, 400, `/objects/${resourceType}`, 'GET', { field: 'record_id', fieldType: 'uuid', resourceType, documentationHint: `Expected UUID format (e.g., 'a1b2c3d4-e5f6-7890-abcd-ef1234567890').`, } ), /** * Issue #417: Task field mapping template */ TASK_FIELD_MAPPING: (originalField: string, correctField: string) => createEnhancedApiError( `Unknown field '${originalField}' for resource type 'tasks'`, 400, '/objects/tasks', 'POST', { field: originalField, fieldType: 'string', suggestedFields: [correctField], resourceType: 'tasks', documentationHint: `For tasks, use "${correctField}" instead of "${originalField}". Valid task fields: content, status, due_date, assignee_id, record_id.`, } ), /** * Issue #798: Phone number format error template */ PHONE_NUMBER_FORMAT_ERROR: (field: string, resourceType: string) => createEnhancedApiError( `Invalid phone number format for field '${field}'`, 400, `/objects/${resourceType}`, 'POST', { field, fieldType: 'phone_number', resourceType, documentationHint: `Phone numbers require 'original_phone_number' key, not 'phone_number'. Example: [{"original_phone_number": "+1-555-0100"}]. E.164 format (+country code) recommended. The system will auto-normalize most formats.`, } ), /** * Generic enhanced error template */ GENERIC: ( message: string, statusCode: number, endpoint: string, method: string, context?: Partial<EnhancedApiErrorContext> ) => createEnhancedApiError(message, statusCode, endpoint, method, context), }; /** * Error enhancement utilities */ export class ErrorEnhancer { /** * Enhance a standard API error with context */ static enhance( error: Error | AttioApiError, context?: Partial<EnhancedApiErrorContext> ): EnhancedApiError { if (error instanceof EnhancedApiError) { return error; // Already enhanced } if (error instanceof AttioApiError) { return new EnhancedApiError( error.message, error.statusCode, error.endpoint, error.method, context ); } // Generic error - make reasonable assumptions return new EnhancedApiError(error.message, 500, '/unknown', 'UNKNOWN', { originalError: error, ...context, }); } /** * Issue #425: Safe error message extraction utility * Extracts a contextual message from any error type safely * Handles: EnhancedApiError, AttioApiError, UniversalValidationError, and generic errors */ static getErrorMessage( error: | Error | EnhancedApiError | AttioApiError | { message?: string } | unknown ): string { // If it's an EnhancedApiError, use getContextualMessage if (error instanceof EnhancedApiError) { return error.getContextualMessage(); } // If it's an AttioApiError, UniversalValidationError, or has a message property, use that if ( error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' ) { return error.message; } // Fallback to string representation return String(error); } /** * Issue #425: Convert any error to EnhancedApiError * Ensures all errors are properly enhanced for consistent handling */ static ensureEnhanced( error: | Error | EnhancedApiError | AttioApiError | { message?: string; statusCode?: number; status?: number; endpoint?: string; path?: string; method?: string; } | unknown, defaultContext?: Partial<EnhancedApiErrorContext> ): EnhancedApiError { if (error instanceof EnhancedApiError) { return error; } // Handle AttioApiError from axios interceptor if (error instanceof AttioApiError) { return new EnhancedApiError( error.message, error.statusCode, error.endpoint, error.method, defaultContext ); } // Handle generic errors with status codes const errorObj = error as { message?: string; statusCode?: number; status?: number; endpoint?: string; path?: string; method?: string; }; const statusCode = errorObj?.statusCode || errorObj?.status || 500; const endpoint = errorObj?.endpoint || errorObj?.path || '/unknown'; const method = errorObj?.method || 'UNKNOWN'; return new EnhancedApiError( errorObj?.message || 'An error occurred', statusCode, endpoint, method, { originalError: error && typeof error === 'object' ? (error as Error) : undefined, ...defaultContext, } ); } /** * Auto-detect error type and apply appropriate enhancement */ static autoEnhance( error: Error, resourceType?: string, operation?: string, recordId?: string ): EnhancedApiError { const message = error.message.toLowerCase(); // Issue #416: Detect "not found" vs "invalid format" scenarios if (message.includes('not found') && recordId && isValidUUID(recordId)) { return ErrorTemplates.RECORD_NOT_FOUND( recordId, resourceType || 'unknown' ); } if ( (message.includes('invalid') || message.includes('format')) && recordId && !isValidUUID(recordId) ) { return ErrorTemplates.INVALID_UUID_FORMAT( recordId, resourceType || 'unknown' ); } // Issue #417: Detect task field mapping issues if (resourceType === 'tasks') { const taskFieldMatch = message.match(/unknown field['\s]*([^']*)/i); if (taskFieldMatch) { const field = taskFieldMatch[1].replace(/['"]/g, '').trim(); const correctField = this.getTaskFieldMapping(field); if (correctField) { return ErrorTemplates.TASK_FIELD_MAPPING(field, correctField); } } } // Issue #415: Detect invalid select options // Fixed: ReDoS vulnerability (Issue #106) - replaced greedy quantifiers const selectMatch = message.match( /invalid value\s*['"]?([^'"]+)['"]?\s*for field\s*['"]?([^'"]+)['"]?/i ); if (selectMatch) { const [, value, field] = selectMatch; return ErrorTemplates.INVALID_SELECT_OPTION( field, value, [], resourceType ); } // Generic enhancement return this.enhance(error, { resourceType, operation, recordId }); } /** * Get correct task field mapping */ private static getTaskFieldMapping(field: string): string | null { const mappings: Record<string, string> = { title: 'content', name: 'content', description: 'content', assignee: 'assignee_id', due: 'due_date', record: 'record_id', }; return mappings[field.toLowerCase()] || null; } }

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