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/.
170 lines
8.5 KiB
TypeScript
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(); }
|
|
});
|
|
});
|