/**
* HTTP Client Wrapper for IRCAM Amplify API
*/
import { MCPError, ErrorCode } from '../types/mcp-tools.js';
import { IRCAM_API_CONFIG } from '../types/ircam-api.js';
import { createAuthHeaders } from './auth.js';
import { formatError } from './errors.js';
/**
* HTTP request options
*/
export interface HttpRequestOptions {
/** Request timeout in milliseconds */
timeout?: number;
/** Number of retries on transient failures */
retries?: number;
/** Custom headers to merge with auth headers */
headers?: Record<string, string>;
}
/**
* HTTP response wrapper
*/
export interface HttpResponse<T> {
ok: boolean;
status: number;
data?: T;
error?: MCPError;
}
/**
* Default request options
*/
const DEFAULT_OPTIONS: Required<HttpRequestOptions> = {
timeout: IRCAM_API_CONFIG.DEFAULT_TIMEOUT,
retries: IRCAM_API_CONFIG.MAX_RETRIES,
headers: {},
};
/**
* Sleep helper for retry delays
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if an error is retryable
*/
function isRetryableError(status: number): boolean {
return status === 429 || status === 502 || status === 503 || status === 504;
}
/**
* Map HTTP status to error code
*/
function httpStatusToErrorCode(status: number): ErrorCode {
switch (status) {
case 401:
return 'INVALID_API_KEY';
case 400:
return 'INVALID_URL';
case 413:
return 'FILE_TOO_LARGE';
case 429:
return 'RATE_LIMITED';
case 404:
return 'JOB_NOT_FOUND';
case 500:
return 'JOB_FAILED';
case 502:
case 503:
case 504:
return 'SERVICE_UNAVAILABLE';
default:
return 'UNKNOWN_ERROR';
}
}
/**
* Execute HTTP request with timeout, retries, and error handling
*/
export async function httpRequest<T>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
body?: unknown,
options: HttpRequestOptions = {}
): Promise<HttpResponse<T>> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const headers = {
...createAuthHeaders(),
...opts.headers,
};
let lastError: MCPError | undefined;
let retryAfter = 0;
for (let attempt = 0; attempt <= opts.retries; attempt++) {
// Wait before retry (with exponential backoff or Retry-After)
if (attempt > 0) {
const delay = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, attempt) * 1000;
await sleep(delay);
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), opts.timeout);
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Check for Retry-After header
const retryAfterHeader = response.headers.get('Retry-After');
if (retryAfterHeader) {
retryAfter = parseInt(retryAfterHeader, 10) || 0;
}
// Success
if (response.ok) {
const data = (await response.json()) as T;
return { ok: true, status: response.status, data };
}
// Retryable error
if (isRetryableError(response.status) && attempt < opts.retries) {
lastError = formatError(httpStatusToErrorCode(response.status), undefined, retryAfter);
continue;
}
// Non-retryable error
let errorMessage: string | undefined;
try {
const errorBody = (await response.json()) as { error?: { message?: string } };
errorMessage = errorBody.error?.message;
} catch {
// Ignore JSON parse errors
}
return {
ok: false,
status: response.status,
error: formatError(httpStatusToErrorCode(response.status), errorMessage, retryAfter),
};
} catch (error) {
// Timeout or network error
if (error instanceof Error && error.name === 'AbortError') {
lastError = formatError('SERVICE_UNAVAILABLE', 'Request timed out');
} else {
lastError = formatError('SERVICE_UNAVAILABLE', 'Network error');
}
// Retry network errors
if (attempt < opts.retries) {
continue;
}
}
}
// All retries exhausted
return {
ok: false,
status: 0,
error: lastError || formatError('UNKNOWN_ERROR'),
};
}
/**
* GET request helper
*/
export async function httpGet<T>(
url: string,
options?: HttpRequestOptions
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, 'GET', undefined, options);
}
/**
* POST request helper
*/
export async function httpPost<T>(
url: string,
body: unknown,
options?: HttpRequestOptions
): Promise<HttpResponse<T>> {
return httpRequest<T>(url, 'POST', body, options);
}
/**
* Build full API URL
*/
export function buildApiUrl(endpoint: string, params?: Record<string, string>): string {
const url = new URL(endpoint, IRCAM_API_CONFIG.BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return url.toString();
}