Skip to main content
Glama
float-api.ts20.1 kB
import { logger } from '../utils/logger.js'; import { appConfig } from '../config/index.js'; import { z } from 'zod'; import { XMLParser, XMLBuilder } from 'fast-xml-parser'; // Response format types export type ResponseFormat = 'json' | 'xml'; // Base error class for Float API errors export class FloatApiError extends Error { constructor( message: string, public status?: number, public data?: unknown, public code?: string ) { super(message); this.name = 'FloatApiError'; this.code = code; } } // Rate limit error (429 responses) export class FloatRateLimitError extends FloatApiError { constructor( message: string, public retryAfter?: number, public data?: unknown ) { super(message, 429, data, 'RATE_LIMIT_EXCEEDED'); this.name = 'FloatRateLimitError'; this.retryAfter = retryAfter; } } // Authentication error (401 responses) export class FloatAuthError extends FloatApiError { constructor( message: string, public data?: unknown ) { super(message, 401, data, 'AUTHENTICATION_FAILED'); this.name = 'FloatAuthError'; } } // Authorization error (403 responses) export class FloatAuthorizationError extends FloatApiError { constructor( message: string, public data?: unknown ) { super(message, 403, data, 'AUTHORIZATION_FAILED'); this.name = 'FloatAuthorizationError'; } } // Validation error (400 responses) export class FloatValidationError extends FloatApiError { constructor( message: string, public validationErrors?: Record<string, string[]>, public data?: unknown ) { super(message, 400, data, 'VALIDATION_ERROR'); this.name = 'FloatValidationError'; this.validationErrors = validationErrors; } } // Not found error (404 responses) export class FloatNotFoundError extends FloatApiError { constructor( message: string, public resourceType?: string, public resourceId?: string ) { super(message, 404, undefined, 'RESOURCE_NOT_FOUND'); this.name = 'FloatNotFoundError'; this.resourceType = resourceType; this.resourceId = resourceId; } } // Server error (5xx responses) export class FloatServerError extends FloatApiError { constructor( message: string, status: number, public data?: unknown ) { super(message, status, data, 'SERVER_ERROR'); this.name = 'FloatServerError'; } } // Network/connection error export class FloatNetworkError extends FloatApiError { constructor( message: string, public originalError?: Error ) { super(message, undefined, undefined, 'NETWORK_ERROR'); this.name = 'FloatNetworkError'; this.originalError = originalError; } } // Response parsing error export class FloatParseError extends FloatApiError { constructor( message: string, public originalError?: Error, public rawResponse?: string ) { super(message, undefined, undefined, 'PARSE_ERROR'); this.name = 'FloatParseError'; this.originalError = originalError; this.rawResponse = rawResponse; } } // Schema validation error export class FloatSchemaValidationError extends FloatApiError { constructor( message: string, public validationError: Error, public receivedData?: unknown ) { super(message, undefined, undefined, 'SCHEMA_VALIDATION_ERROR'); this.name = 'FloatSchemaValidationError'; this.validationError = validationError; this.receivedData = receivedData; } } // XML parser configuration const xmlParserOptions = { ignoreAttributes: false, parseAttributeValue: true, parseTagValue: true, trimValues: true, parseTrueNumberOnly: false, attributeNamePrefix: '@_', textNodeName: '#text', ignoreNameSpace: false, removeNSPrefix: false, parseNodeValue: true, cdataTagName: '__cdata', cdataPositionChar: '\\c', arrayMode: false, processEntities: true, htmlEntities: false, ignoreDeclaration: false, ignorePiTags: false, }; const xmlParser = new XMLParser(xmlParserOptions); // Format conversion utilities export class FormatConverter { static parseXmlToJson(xmlString: string): Record<string, unknown> { try { return xmlParser.parse(xmlString) as Record<string, unknown>; } catch (error) { logger.error('Failed to parse XML:', error); throw new FloatApiError('Invalid XML format in response'); } } static jsonToXml(jsonData: Record<string, unknown>): string { try { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, suppressEmptyNode: true, attributeNamePrefix: '@_', textNodeName: '#text', }); return builder.build(jsonData); } catch (error) { logger.error('Failed to convert JSON to XML:', error); throw new FloatApiError('Failed to convert response to XML format'); } } static getAcceptHeader(format: ResponseFormat): string { switch (format) { case 'json': return 'application/json'; case 'xml': return 'application/xml, text/xml'; default: return 'application/json'; } } static getContentType(format: ResponseFormat): string { switch (format) { case 'json': return 'application/json'; case 'xml': return 'application/xml'; default: return 'application/json'; } } static processResponse<T>( data: T, format: ResponseFormat, originalFormat: ResponseFormat ): T | string { // If requested format matches original format, return as-is if (format === originalFormat) { return data; } // Convert between formats if (originalFormat === 'json' && format === 'xml') { return FormatConverter.jsonToXml(data as Record<string, unknown>); } else if (originalFormat === 'xml' && format === 'json') { return FormatConverter.parseXmlToJson(data as string) as T; } return data; } } // Request queue for rate limiting let requestQueue: number[] = []; let cleanupInterval: NodeJS.Timeout | null = null; // Clean up old requests const startCleanup = (): void => { if (cleanupInterval) return; cleanupInterval = setInterval(() => { const now = Date.now(); requestQueue = requestQueue.filter( (timestamp) => now - timestamp < appConfig.rateLimitWindowMs ); }, 1000); }; // Stop cleanup export const stopCleanup = (): void => { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; } }; // Wait for rate limit const waitForRateLimit = async (): Promise<void> => { startCleanup(); while (requestQueue.length >= appConfig.rateLimitMaxRequests) { await new Promise((resolve) => setTimeout(resolve, 100)); } requestQueue.push(Date.now()); }; // Centralized error handler for Float API responses class FloatErrorHandler { static createErrorFromResponse( response: Response, errorData: Record<string, unknown> | null ): FloatApiError { const status = response.status; const statusText = response.statusText; switch (status) { case 400: return new FloatValidationError( `Validation failed: ${errorData?.message || statusText}`, (errorData?.errors || errorData?.validation_errors) as | Record<string, string[]> | undefined, errorData ); case 401: return new FloatAuthError( `Authentication failed: ${errorData?.message || 'Invalid or missing API key'}`, errorData ); case 403: return new FloatAuthorizationError( `Authorization failed: ${errorData?.message || 'Insufficient permissions'}`, errorData ); case 404: return new FloatNotFoundError( `Resource not found: ${errorData?.message || statusText}`, errorData?.resource_type as string | undefined, errorData?.resource_id as string | undefined ); case 429: { const retryAfter = response.headers.get('Retry-After'); return new FloatRateLimitError( `Rate limit exceeded: ${errorData?.message || 'Too many requests'}`, retryAfter ? parseInt(retryAfter, 10) : undefined, errorData ); } case 500: case 502: case 503: case 504: return new FloatServerError( `Server error: ${errorData?.message || statusText}`, status, errorData ); default: return new FloatApiError( `Float API request failed: ${status} ${statusText}`, status, errorData, 'UNKNOWN_ERROR' ); } } static async handleNetworkError(error: Error, url: string, method: string): Promise<never> { logger.error('Network error:', { url, method, message: error.message, stack: error.stack, }); // Check for common network errors if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { throw new FloatNetworkError( `Cannot connect to Float API at ${url}. Please check your internet connection and API base URL.`, error ); } if (error.message.includes('timeout')) { throw new FloatNetworkError( `Request to Float API timed out. The service may be experiencing high load.`, error ); } throw new FloatNetworkError( `Network error when connecting to Float API: ${error.message}`, error ); } static formatErrorForMcp(error: FloatApiError): { success: false; error: string; errorCode?: string; details?: Record<string, unknown>; } { const result: { success: false; error: string; errorCode?: string; details?: Record<string, unknown>; } = { success: false, error: error.message, }; if (error.code) { result.errorCode = error.code; } // Add specific error details based on error type if (error instanceof FloatValidationError && error.validationErrors) { result.details = { validationErrors: error.validationErrors, }; } else if (error instanceof FloatRateLimitError && error.retryAfter) { result.details = { retryAfter: error.retryAfter, }; } else if (error instanceof FloatNotFoundError) { result.details = { resourceType: error.resourceType, resourceId: error.resourceId, }; } else if (error instanceof FloatNetworkError && error.originalError) { result.details = { networkError: error.originalError.message, }; } else if (error instanceof FloatSchemaValidationError) { result.details = { schemaValidationError: error.validationError.message, receivedData: error.receivedData, }; } return result; } } export class FloatApi { private baseURL: string; private apiKey: string; constructor(apiKey?: string, baseURL?: string) { this.apiKey = apiKey || appConfig.floatApiKey; this.baseURL = baseURL || appConfig.floatApiBaseUrl; } // Enhanced rate limit handling with retry logic private async handleRateLimitRetry<T>( operation: () => Promise<T>, maxRetries: number = 3, baseDelay: number = 1000 ): Promise<T> { let lastError: FloatRateLimitError | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { if (error instanceof FloatRateLimitError) { lastError = error; if (attempt === maxRetries) { logger.error('Rate limit exceeded after all retries:', { attempts: attempt + 1, retryAfter: error.retryAfter, }); throw error; } // Calculate delay: use Retry-After header if available, otherwise exponential backoff const delay = error.retryAfter ? error.retryAfter * 1000 : baseDelay * Math.pow(2, attempt); logger.warn('Rate limit exceeded, retrying...', { attempt: attempt + 1, maxRetries: maxRetries + 1, delayMs: delay, retryAfter: error.retryAfter, }); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } // Re-throw non-rate-limit errors immediately throw error; } } throw lastError || new FloatRateLimitError('Rate limit exceeded after all retries'); } private async makeRequest<T>( method: string, url: string, data?: unknown, schema?: z.ZodType<T>, format: ResponseFormat = 'json' ): Promise<T> { await waitForRateLimit(); const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': FormatConverter.getContentType(format), Accept: FormatConverter.getAcceptHeader(format), 'User-Agent': 'Float MCP Server v0.3.2 (github.com/asachs01/float-mcp)', // Required by Float API }; const requestUrl = `${this.baseURL}${url}`; const requestOptions: RequestInit = { method, headers, }; if (data) { if (format === 'xml') { requestOptions.body = FormatConverter.jsonToXml(data as Record<string, unknown>); } else { requestOptions.body = JSON.stringify(data); } } try { logger.debug('API Request:', { url: requestUrl, method, format, data: data ? format === 'xml' ? FormatConverter.jsonToXml(data as Record<string, unknown>) : JSON.stringify(data) : undefined, }); const response = await fetch(requestUrl, requestOptions); logger.debug('API Response:', { url: requestUrl, method, status: response.status, statusText: response.statusText, }); return this.handleResponse(response, schema, format); } catch (error) { // Handle network errors (fetch failures) if (error instanceof TypeError && error.message.includes('fetch')) { await FloatErrorHandler.handleNetworkError(error, requestUrl, method); } // Re-throw FloatApiError instances (from handleResponse) if (error instanceof FloatApiError) { throw error; } // Handle other unexpected errors logger.error('Unexpected API Error:', { url: requestUrl, method, message: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined, }); throw new FloatApiError( `Unexpected error during API request: ${error instanceof Error ? error.message : 'Unknown error'}`, undefined, error, 'UNEXPECTED_ERROR' ); } } private async handleResponse<T>( response: Response, schema?: z.ZodType<T>, requestedFormat: ResponseFormat = 'json' ): Promise<T> { if (!response.ok) { let errorData: Record<string, unknown> | null = null; try { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/xml') || contentType.includes('text/xml')) { const xmlText = await response.text(); errorData = FormatConverter.parseXmlToJson(xmlText); } else { errorData = await response.json(); } } catch (parseError) { // If we can't parse the error response, use the status text errorData = { message: response.statusText }; } logger.error('API Error Response:', { status: response.status, statusText: response.statusText, errorData, format: requestedFormat, }); throw FloatErrorHandler.createErrorFromResponse(response, errorData); } let data: unknown; let originalFormat: ResponseFormat = 'json'; try { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/xml') || contentType.includes('text/xml')) { originalFormat = 'xml'; const xmlText = await response.text(); data = FormatConverter.parseXmlToJson(xmlText); } else { originalFormat = 'json'; data = await response.json(); } } catch (parseError) { logger.error('Failed to parse API response:', parseError); const rawResponse = await response.text(); throw new FloatParseError( `Invalid ${originalFormat.toUpperCase()} response from Float API`, parseError instanceof Error ? parseError : new Error(String(parseError)), rawResponse ); } logger.debug('API Response parsed:', { status: response.status, dataType: typeof data, isArray: Array.isArray(data), dataLength: Array.isArray(data) ? data.length : undefined, originalFormat, requestedFormat, }); if (schema) { try { const validatedData = schema.parse(data); // Apply format conversion after validation return FormatConverter.processResponse(validatedData, requestedFormat, originalFormat) as T; } catch (error) { logger.error('Schema validation failed:', { error: error instanceof Error ? error.message : 'Unknown validation error', receivedData: data, originalFormat, requestedFormat, }); throw new FloatSchemaValidationError( `API response validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : new Error(String(error)), data ); } } // Apply format conversion for non-schema responses return FormatConverter.processResponse(data, requestedFormat, originalFormat) as T; } async get<T>(url: string, schema?: z.ZodType<T>, format: ResponseFormat = 'json'): Promise<T> { return this.handleRateLimitRetry(() => this.makeRequest<T>('GET', url, undefined, schema, format) ); } async post<T>( url: string, data: unknown, schema?: z.ZodType<T>, format: ResponseFormat = 'json' ): Promise<T> { return this.handleRateLimitRetry(() => this.makeRequest<T>('POST', url, data, schema, format)); } async put<T>( url: string, data: unknown, schema?: z.ZodType<T>, format: ResponseFormat = 'json' ): Promise<T> { return this.handleRateLimitRetry(() => this.makeRequest<T>('PUT', url, data, schema, format)); } async patch<T>( url: string, data: unknown, schema?: z.ZodType<T>, format: ResponseFormat = 'json' ): Promise<T> { return this.handleRateLimitRetry(() => this.makeRequest<T>('PATCH', url, data, schema, format)); } async delete<T>(url: string, schema?: z.ZodType<T>, format: ResponseFormat = 'json'): Promise<T> { return this.handleRateLimitRetry(() => this.makeRequest<T>('DELETE', url, undefined, schema, format) ); } // Helper method to build query parameters buildQueryParams(params: Record<string, unknown>): string { const queryParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { queryParams.append(key, String(value)); } }); const queryString = queryParams.toString(); return queryString ? `?${queryString}` : ''; } // Helper method for paginated requests async getPaginated<T>( url: string, params?: Record<string, unknown>, schema?: z.ZodType<T[]>, format: ResponseFormat = 'json' ): Promise<T[]> { const queryString = this.buildQueryParams({ ...params, 'per-page': params?.['per-page'] || 200, // Float API max page size }); return this.get<T[]>(`${url}${queryString}`, schema, format); } } // Create and export a default instance export const floatApi = new FloatApi(); // Export error handler for external use export { FloatErrorHandler };

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/asachs01/float-mcp'

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