Files
CurrenciCombo/src/services/http.ts

58 lines
1.8 KiB
TypeScript
Raw Normal View History

/**
* Thin fetch wrapper with timeout + JSON handling + typed errors.
* Keep this dependency-free so every service can share it.
*/
export class HttpError extends Error {
readonly status: number;
readonly statusText: string;
readonly url: string;
readonly body?: unknown;
constructor(status: number, statusText: string, url: string, body?: unknown) {
super(`HTTP ${status} ${statusText} (${url})`);
this.name = 'HttpError';
this.status = status;
this.statusText = statusText;
this.url = url;
this.body = body;
}
}
export interface HttpOptions extends Omit<RequestInit, 'body'> {
/** Request body — automatically JSON-stringified when an object. */
body?: unknown;
/** Abort the request after N ms. Default 10000. */
timeoutMs?: number;
}
export async function httpJson<T>(url: string, opts: HttpOptions = {}): Promise<T> {
const { body, timeoutMs = 10_000, headers, ...rest } = opts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
...rest,
signal: controller.signal,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
});
if (!res.ok) {
let parsed: unknown;
try { parsed = await res.json(); } catch { parsed = await res.text().catch(() => undefined); }
throw new HttpError(res.status, res.statusText, url, parsed);
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return (await res.json()) as T;
return (await res.text()) as unknown as T;
} finally {
clearTimeout(timer);
}
}