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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
170
packages/connector-sdk/src/http.test.ts
Normal file
170
packages/connector-sdk/src/http.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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(); }
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue