feat(sdk): plugin contract, http client, fixture harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
27ec24eb4b
commit
a14c4e7747
@@ -0,0 +1,101 @@
|
||||
import type { HttpClient } from "./types.ts";
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
constructor(status: number, body: string, url = "") {
|
||||
super(`HTTP ${status}${url ? ` for ${url}` : ""}`);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpClientOptions {
|
||||
ratePerMinute?: number;
|
||||
burst?: number;
|
||||
maxRetries?: number;
|
||||
userAgent: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const retryable = (status: number) => status === 429 || status >= 500;
|
||||
|
||||
function retryDelayMs(res: Response, attempt: number, now: number): number {
|
||||
const ra = res.headers.get("retry-after");
|
||||
if (ra !== null && ra.trim() !== "") {
|
||||
const secs = Number(ra);
|
||||
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
||||
const at = Date.parse(ra);
|
||||
if (!Number.isNaN(at)) return Math.max(0, at - now);
|
||||
}
|
||||
const reset = res.headers.get("x-ratelimit-reset-in");
|
||||
if (reset !== null) {
|
||||
const secs = Number(reset);
|
||||
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
||||
}
|
||||
return 500 * 2 ** attempt;
|
||||
}
|
||||
|
||||
export function createHttpClient(opts: HttpClientOptions): HttpClient {
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||
const now = opts.now ?? Date.now;
|
||||
const maxRetries = opts.maxRetries ?? 3;
|
||||
|
||||
// Token bucket: capacity `burst`, refilling at ratePerMinute / 60000 tokens per ms.
|
||||
const perMs = opts.ratePerMinute ? opts.ratePerMinute / 60_000 : 0;
|
||||
const capacity = Math.max(1, opts.burst ?? 1);
|
||||
let tokens = capacity;
|
||||
let last = now();
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function take(): Promise<void> {
|
||||
for (;;) {
|
||||
const t = now();
|
||||
tokens = Math.min(capacity, tokens + (t - last) * perMs);
|
||||
last = t;
|
||||
if (tokens >= 1 - 1e-9) {
|
||||
tokens -= 1;
|
||||
return;
|
||||
}
|
||||
await sleep(Math.ceil((1 - tokens) / perMs));
|
||||
}
|
||||
}
|
||||
function acquire(): Promise<void> {
|
||||
if (!perMs) return Promise.resolve();
|
||||
const p = queue.then(take);
|
||||
queue = p.catch(() => {});
|
||||
return p;
|
||||
}
|
||||
|
||||
async function send(url: string, init: RequestInit | undefined): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("user-agent")) headers.set("user-agent", opts.userAgent);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
await acquire();
|
||||
const res = await doFetch(url, { ...init, headers });
|
||||
if (!retryable(res.status)) return res;
|
||||
if (attempt >= maxRetries) throw new HttpError(res.status, await res.text(), url);
|
||||
const delay = retryDelayMs(res, attempt, now());
|
||||
await res.body?.cancel();
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: (url, init) => send(url, init),
|
||||
async json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T> {
|
||||
const { query, ...rest } = init ?? {};
|
||||
const u = new URL(url);
|
||||
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) u.searchParams.set(k, String(v));
|
||||
const headers = new Headers(rest.headers);
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
const res = await send(u.toString(), { ...rest, headers });
|
||||
if (!res.ok) throw new HttpError(res.status, await res.text(), u.toString());
|
||||
return (await res.json()) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user