BID · Console
Baseline · Intelligence · Decision
src/tools/retrieval/http-client.ts 3,241 bytes · typescript
/**
 * Shared HTTP infrastructure for retrieval connectors.
 *
 * Thin wrapper over global `fetch` that:
 *   - applies sane defaults (User-Agent, timeout, max body size)
 *   - normalises errors into HttpError so connectors can re-throw as
 *     RetrievalError without losing context (Std 12).
 *
 * Connectors should prefer this over reaching for `fetch` directly so
 * the framework controls outbound behaviour (rate-limit hooks, retries,
 * logging) in one place.
 */

export interface HttpRequestOptions {
  readonly method?: 'GET' | 'POST' | 'HEAD';
  readonly headers?: Record<string, string>;
  readonly body?: string | Uint8Array;
  /** Abort timeout in milliseconds. Default 15s. */
  readonly timeoutMs?: number;
  /** Maximum response body size in bytes. Default 5 MiB. */
  readonly maxBodyBytes?: number;
}

export interface HttpResponse {
  readonly status: number;
  readonly contentType: string;
  readonly url: string;
  readonly body: string;
  readonly headers: Record<string, string>;
}

export class HttpError extends Error {
  constructor(
    public readonly category: 'timeout' | 'network' | 'status' | 'body-too-large' | 'aborted',
    message: string,
    public readonly status?: number,
    public readonly url?: string,
  ) {
    super(message);
    this.name = 'HttpError';
  }
}

const DEFAULT_USER_AGENT = 'bid-poc/0.1 (+https://example.invalid/bid-poc)';
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_MAX_BODY = 5 * 1024 * 1024;

export async function httpRequest(url: string, opts: HttpRequestOptions = {}): Promise<HttpResponse> {
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const maxBody = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method: opts.method ?? 'GET',
      headers: { 'User-Agent': DEFAULT_USER_AGENT, ...opts.headers },
      body: opts.body,
      signal: ctrl.signal,
    });
    if (!res.ok) {
      throw new HttpError('status', `HTTP ${res.status} ${res.statusText} for ${url}`, res.status, url);
    }
    const buf = await res.arrayBuffer();
    if (buf.byteLength > maxBody) {
      throw new HttpError('body-too-large', `response body ${buf.byteLength}B exceeds ${maxBody}B for ${url}`, res.status, url);
    }
    const body = new TextDecoder('utf-8', { fatal: false }).decode(buf);
    const headers: Record<string, string> = {};
    res.headers.forEach((v, k) => {
      headers[k] = v;
    });
    return {
      status: res.status,
      contentType: res.headers.get('content-type') ?? 'application/octet-stream',
      url: res.url,
      body,
      headers,
    };
  } catch (err) {
    if (err instanceof HttpError) throw err;
    if (err instanceof DOMException && err.name === 'AbortError') {
      throw new HttpError('timeout', `request to ${url} timed out after ${timeoutMs}ms`, undefined, url);
    }
    throw new HttpError('network', err instanceof Error ? err.message : String(err), undefined, url);
  } finally {
    clearTimeout(timer);
  }
}

export async function httpGet(url: string, opts: HttpRequestOptions = {}): Promise<HttpResponse> {
  return httpRequest(url, { ...opts, method: 'GET' });
}