/**
* 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' });
}