feat: add shared HTTP response and retry contract

This commit is contained in:
donghyeon-ka
2026-07-25 20:49:50 +09:00
parent b193feeddc
commit 5c36cd978c
5 changed files with 686 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
let attempts = 0;
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
attempts += 1;
if (attempts < 3) {
return HttpResponse.json(
{ success: false, error: { code: "TEMPORARY" } },
{ status: 503 },
);
}
return HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-1", traceId: "trace-1" },
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
attempts = 0;
server.resetHandlers();
});
afterAll(() => server.close());
const clock = {
now: () => 0,
sleep: async () => {},
};
describe("shared HTTP client", () => {
it("retries a safe request at most twice and returns validated data", async () => {
const client = createHttpClient({
baseUrl: "https://api.test",
clock,
random: () => 0,
});
await expect(
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
).resolves.toMatchObject({
ok: true,
value: [{ id: "resource-1" }],
meta: { requestId: "request-1" },
});
expect(attempts).toBe(3);
});
it("rejects a non-JSON response without exposing its body", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_SAMPLE_RESOURCES");
expect(result).toMatchObject({
ok: false,
error: { kind: "CONTENT_TYPE_MISMATCH" },
});
expect(JSON.stringify(result)).not.toContain("raw body");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
calculateBackoff,
parseRetryAfter,
retryDelay,
shouldRetry,
} from "../../src/adapters/http/retry-policy.js";
describe("HTTP retry policy", () => {
it("uses capped exponential full jitter", () => {
expect(calculateBackoff(0, () => 0.5)).toBe(125);
expect(calculateBackoff(8, () => 1)).toBe(2_000);
});
it("parses retry-after and chooses the longer bounded delay", () => {
expect(parseRetryAfter("2", 0)).toBe(2_000);
expect(retryDelay({ kind: "RATE_LIMITED", retryAfterMs: 500 }, 0, () => 0)).toBe(
500,
);
});
it("allows at most two retries for safe or keyed requests", () => {
const failure = { kind: "SERVER_FAILURE" };
expect(shouldRetry({ idempotency: "safe" }, failure, 0)).toBe(true);
expect(shouldRetry({ idempotency: "keyed" }, failure, 1)).toBe(true);
expect(shouldRetry({ idempotency: "safe" }, failure, 2)).toBe(false);
expect(shouldRetry({ idempotency: "none" }, failure, 0)).toBe(false);
expect(
shouldRetry(
{ idempotency: "safe" },
{ kind: "SERVER_FAILURE", httpStatus: 500 },
0,
),
).toBe(false);
});
it("does not automatically wait beyond 30 seconds", () => {
expect(
shouldRetry(
{ idempotency: "safe" },
{ kind: "RATE_LIMITED", retryAfterMs: 31_000 },
0,
),
).toBe(false);
});
});