T-HUB/packages/connector-sdk/src/http.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

170 lines
8.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { deliveryCertaintyOf, ProviderServerError, ProviderTransportError, RateLimitError } from "./errors.js";
import { RateLimitedHttpClient, setHttpRequestHookProvider, type HttpRequestEvent } from "./http.js";
const URL_ = "https://api.example.test/v1/things";
function scripted(responses: (() => Response)[]): { fetchImpl: typeof fetch; calls: RequestInit[] } {
const calls: RequestInit[] = [];
const fetchImpl = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
calls.push(init ?? {});
const next = responses[Math.min(calls.length - 1, responses.length - 1)];
if (next === undefined) throw new Error("no scripted response");
return next();
}) as typeof fetch;
return { fetchImpl, calls };
}
function client(fetchImpl: typeof fetch): RateLimitedHttpClient {
return new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1000, sleep: async () => Promise.resolve() });
}
afterEach(() => setHttpRequestHookProvider(undefined));
describe("http client retry classification", () => {
it("retries idempotent GET on 503 and 429", async () => {
const { fetchImpl, calls } = scripted([
() => new Response("", { status: 503 }),
() => new Response("", { status: 429 }),
() => new Response("ok", { status: 200 }),
]);
const response = await client(fetchImpl).request("p", URL_);
expect(response.status).toBe(200);
expect(calls).toHaveLength(3);
});
it("retries PUT and DELETE", async () => {
for (const method of ["PUT", "DELETE"]) {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 502 }), () => new Response(null, { status: 204 })]);
await client(fetchImpl).request("p", URL_, { method });
expect(calls).toHaveLength(2);
}
});
it("never resends a POST on 429: one call, RateLimitError with header Retry-After", async () => {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "7" } })]);
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toHaveLength(1);
expect(error).toBeInstanceOf(RateLimitError);
expect(error).toMatchObject({ retryAfterMs: 7000, delivery: "rejected", status: 429 });
});
it("reads retry_after from a JSON 429 body when no header is present", async () => {
const { fetchImpl } = scripted([
() => new Response(JSON.stringify({ ok: false, error_code: 429, parameters: { retry_after: 3 } }), { status: 429 }),
]);
await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toMatchObject({ retryAfterMs: 3000 });
});
it("surfaces POST 5xx as ProviderServerError with unknown delivery, without resending", async () => {
const { fetchImpl, calls } = scripted([() => new Response("boom", { status: 500 })]);
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toHaveLength(1);
expect(error).toBeInstanceOf(ProviderServerError);
expect(error).not.toBeInstanceOf(RateLimitError);
expect(error).toMatchObject({ status: 500, method: "POST", delivery: "unknown" });
expect(deliveryCertaintyOf(error)).toBe("unknown");
});
it("wraps POST transport loss as ProviderTransportError (unknown delivery)", async () => {
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
throw new TypeError("socket hang up");
}) as typeof fetch;
const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e);
expect(calls).toBe(1);
expect(error).toBeInstanceOf(ProviderTransportError);
expect(deliveryCertaintyOf(error)).toBe("unknown");
});
it("returns 4xx responses to the caller untouched", async () => {
const { fetchImpl } = scripted([() => new Response("{}", { status: 422 })]);
const response = await client(fetchImpl).request("p", URL_, { method: "POST" });
expect(response.status).toBe(422);
});
it("opts a read-only POST into retries and a mutation out of them", async () => {
const retried = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]);
await client(retried.fetchImpl).request("p", URL_, { method: "POST" }, { retry: true });
expect(retried.calls).toHaveLength(2);
const once = scripted([() => new Response("", { status: 503 })]);
await expect(client(once.fetchImpl).request("p", URL_, { method: "PUT" }, { retry: false })).rejects.toBeInstanceOf(
ProviderServerError,
);
expect(once.calls).toHaveLength(1);
});
it("exhausted GET 5xx is a ProviderServerError, not a RateLimitError", async () => {
const { fetchImpl, calls } = scripted([() => new Response("", { status: 503 })]);
const error = await new RateLimitedHttpClient({ fetchImpl, maxRetries: 2, requestsPerSecond: 1000, sleep: async () => Promise.resolve() })
.request("p", URL_)
.catch((e: unknown) => e);
expect(calls).toHaveLength(3);
expect(error).toBeInstanceOf(ProviderServerError);
expect(error).toMatchObject({ delivery: "rejected" });
});
it("exhausted GET 429 stays a RateLimitError carrying Retry-After", async () => {
const { fetchImpl } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "10" } })]);
await expect(client(fetchImpl).request("p", URL_)).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 10_000 });
});
});
describe("http request hook", () => {
it("runs beforeRequest per actual send, without the URL path", async () => {
const events: HttpRequestEvent[] = [];
setHttpRequestHookProvider(() => ({ beforeRequest: (event) => void events.push(event) }));
const { fetchImpl } = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]);
await client(fetchImpl).request("p", "https://api.example.test/bot123:secret/getMe");
expect(events.map((e) => e.attempt)).toEqual([0, 1]);
expect(events[0]).toEqual({ provider: "p", method: "GET", origin: "https://api.example.test", attempt: 0, mutating: false });
expect(JSON.stringify(events)).not.toContain("secret");
});
it("a throwing beforeRequest blocks the send entirely", async () => {
setHttpRequestHookProvider(() => ({
beforeRequest: () => {
throw new Error("fence lost");
},
}));
const { fetchImpl, calls } = scripted([() => new Response("ok")]);
await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toThrow("fence lost");
expect(calls).toHaveLength(0);
});
});
describe("around-send dispatch serialization", () => {
it("holds the gate across the actual response, after the rate limiter", async () => {
const order: string[] = [];
const { fetchImpl } = scripted([() => { order.push("send"); return new Response("ok"); }]);
setHttpRequestHookProvider(() => ({ aroundMutation: async (_event, send) => { order.push("lock"); const response = await send(); order.push("unlock"); return response; } }));
await client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false });
expect(order).toEqual(["lock", "send", "unlock"]);
});
it("preserves gate errors and never classifies them as transport delivery", async () => {
const failure = new Error("gate refused"); const { fetchImpl, calls } = scripted([() => new Response("ok")]);
setHttpRequestHookProvider(() => ({ aroundMutation: async () => { throw failure; } }));
await expect(client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false })).rejects.toBe(failure);
expect(calls).toHaveLength(0);
});
});
describe("rate wait precedes the dispatch gate", () => {
it("does not acquire the mutation gate or stamp a send until the limiter releases", async () => {
vi.useFakeTimers();
try {
const { fetchImpl, calls } = scripted([() => new Response("ok")]);
const gate = vi.fn(async (_event: HttpRequestEvent, send: () => Promise<Response>) => send());
setHttpRequestHookProvider(() => ({ aroundMutation: gate }));
const http = new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1 });
await http.request("p", URL_);
const pending = http.request("p", URL_, { method: "POST" }, { retry: false });
await vi.advanceTimersByTimeAsync(999);
expect(gate).not.toHaveBeenCalled(); expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1); await pending;
expect(gate).toHaveBeenCalledTimes(1); expect(calls).toHaveLength(2);
} finally { vi.useRealTimers(); }
});
});