diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js new file mode 100644 index 0000000..a7a0e63 --- /dev/null +++ b/src/adapters/http/client.js @@ -0,0 +1,438 @@ +import { systemClock } from "../../application/ports/clock-port.js"; +import { getApiOperation } from "../../contracts/api-operations.js"; +import { retryDelay, shouldRetry } from "./retry-policy.js"; + +const noAuthSession = + /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ + getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"), + attach: async (request) => request, + recover: async () => /** @type {"no-session"} */ ("no-session"), + onUnauthenticated: () => {}, +}); + +/** + * @typedef {{ + * kind: string, + * code: string, + * retryable: boolean, + * operationId: string, + * attemptCount: number, + * httpStatus?: number, + * requestId?: string, + * traceId?: string, + * retryAfterMs?: number, + * userMessageKey: string, + * action: string + * }} HttpFailure + */ + +/** + * @typedef {{ ok: true, value: unknown, meta: Record } | + * { ok: false, error: HttpFailure }} HttpResult + */ + +/** + * @typedef {{ + * code?: string, + * httpStatus?: number, + * requestId?: string, + * traceId?: string, + * retryAfterMs?: number + * }} FailureDetails + */ + +/** + * @param {{ + * baseUrl: string, + * fetcher?: typeof fetch, + * authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort, + * clock?: import("../../application/ports/clock-port.js").ClockPort, + * random?: () => number, + * validatePayload?: (schemaId: string, value: unknown) => + * { success: true, data: unknown } | { success: false }, + * idempotencyKeyFactory?: () => string + * }} dependencies + */ +export function createHttpClient(dependencies) { + const fetcher = dependencies.fetcher ?? fetch; + const authSession = dependencies.authSession ?? noAuthSession; + const clock = dependencies.clock ?? systemClock; + const random = dependencies.random ?? Math.random; + const validatePayload = + dependencies.validatePayload ?? + ((_schemaId, value) => ({ success: /** @type {true} */ (true), data: value })); + const idempotencyKeyFactory = + dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); + + /** + * @param {string} operationId + * @param {{ + * body?: unknown, + * routeId?: string, + * signal?: AbortSignal, + * idempotencyKey?: string + * }} [input] + */ + async function execute(operationId, input = {}) { + const operation = getApiOperation(operationId); + const logicalIdempotencyKey = + operation.idempotency === "keyed" + ? input.idempotencyKey ?? idempotencyKeyFactory() + : undefined; + let retryCount = 0; + let recoveryUsed = false; + + while (true) { + const attempt = retryCount; + /** @type {HttpResult} */ + const outcome = await performAttempt({ + operation, + input, + attempt, + idempotencyKey: logicalIdempotencyKey, + }); + + if (outcome.ok) return outcome; + + if (outcome.error.httpStatus === 401 && !recoveryUsed) { + recoveryUsed = true; + const recovered = await recoverSession(authSession, operation, outcome.error); + if (!recovered.ok) return recovered; + if (operation.idempotency === "none") { + return { + ok: false, + error: { + ...outcome.error, + retryable: false, + action: "retry", + }, + }; + } + continue; + } + + if (!shouldRetry(operation, outcome.error, retryCount)) { + return outcome; + } + + const delay = retryDelay(outcome.error, retryCount, random, clock.now()); + retryCount += 1; + + try { + await clock.sleep(delay, input.signal); + } catch { + return { + ok: false, + error: failure("REQUEST_ABORTED", operationId, retryCount, { + code: "REQUEST_ABORTED", + }), + }; + } + } + } + + /** + * @param {{ + * operation: ReturnType, + * input: { body?: unknown, routeId?: string, signal?: AbortSignal }, + * attempt: number, + * idempotencyKey?: string + * }} context + * @returns {Promise} + */ + async function performAttempt(context) { + const { operation, input, attempt, idempotencyKey } = context; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort("timeout"); + }, operation.timeoutMs); + const onExternalAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", onExternalAbort, { once: true }); + + const headers = new Headers({ Accept: "application/json" }); + if (input.body !== undefined) headers.set("Content-Type", "application/json"); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + + let request = new Request(new URL(operation.path, dependencies.baseUrl), { + method: operation.method, + headers, + body: input.body === undefined ? undefined : JSON.stringify(input.body), + signal: controller.signal, + }); + + try { + if (operation.auth === "external-session") { + try { + request = await authSession.attach(request); + } catch { + return { + ok: false, + error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, { + code: "AUTH_ATTACH_FAILED", + }), + }; + } + } + + const response = await fetcher(request); + return await parseResponse(response, operation, attempt, validatePayload); + } catch { + if (timedOut) { + return { + ok: false, + error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, { + code: "REQUEST_TIMEOUT", + }), + }; + } + if (controller.signal.aborted || input.signal?.aborted) { + const externalReason = input.signal?.reason; + if (externalReason === "timeout") { + return { + ok: false, + error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, { + code: "REQUEST_TIMEOUT", + }), + }; + } + if ( + externalReason !== undefined && + !["navigation", "user", "superseded"].includes(String(externalReason)) + ) { + return { + ok: false, + error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, { + code: "EXTERNAL_ABORT_UNRESOLVED", + }), + }; + } + return { + ok: false, + error: failure("REQUEST_ABORTED", operation.operationId, attempt, { + code: "REQUEST_ABORTED", + }), + }; + } + return { + ok: false, + error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, { + code: "NETWORK_UNREACHABLE", + }), + }; + } finally { + clearTimeout(timeout); + input.signal?.removeEventListener("abort", onExternalAbort); + } + } + + return Object.freeze({ execute }); +} + +/** + * @param {Response} response + * @param {import("../../contracts/api-operations.js").ApiOperation} operation + * @param {number} attempt + * @param {(schemaId: string, value: unknown) => + * { success: true, data: unknown } | { success: false }} validatePayload + * @returns {Promise} + */ +async function parseResponse(response, operation, attempt, validatePayload) { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + return { + ok: false, + error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, { + code: "CONTENT_TYPE_MISMATCH", + httpStatus: response.status, + }), + }; + } + + let envelope; + try { + envelope = await response.json(); + } catch { + return { + ok: false, + error: failure("MALFORMED_JSON", operation.operationId, attempt, { + code: "MALFORMED_JSON", + httpStatus: response.status, + }), + }; + } + + if (!envelope || typeof envelope !== "object") { + return { + ok: false, + error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, { + code: "ENVELOPE_MISMATCH", + httpStatus: response.status, + }), + }; + } + + const envelopeRecord = /** @type {Record} */ (envelope); + if (typeof envelopeRecord.success !== "boolean") { + return { + ok: false, + error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, { + code: "ENVELOPE_MISMATCH", + httpStatus: response.status, + }), + }; + } + + if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) { + const payload = validatePayload(operation.responseSchema, envelopeRecord.data); + if (!payload.success) { + return { + ok: false, + error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, { + code: "SCHEMA_MISMATCH", + httpStatus: response.status, + }), + }; + } + + return { + ok: true, + value: structuredClone(payload.data), + meta: safeMeta(envelopeRecord.meta), + }; + } + + const kind = statusKind(response.status); + const retryAfter = response.headers.get("retry-after"); + return { + ok: false, + error: failure(kind, operation.operationId, attempt, { + code: safeBackendCode(envelope), + httpStatus: response.status, + requestId: safeMeta(envelopeRecord.meta).requestId, + traceId: safeMeta(envelopeRecord.meta).traceId, + retryAfterMs: + response.status === 429 && retryAfter + ? parseRetryAfterHeader(retryAfter) + : undefined, + }), + }; +} + +/** + * @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession + * @param {import("../../contracts/api-operations.js").ApiOperation} operation + * @param {HttpFailure} originalFailure + * @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>} + */ +async function recoverSession(authSession, operation, originalFailure) { + try { + const result = await authSession.recover(); + if (result === "restored") return { ok: true }; + if (result === "no-session") { + authSession.onUnauthenticated(); + return { + ok: false, + error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount, { + code: "AUTH_REQUIRED", + httpStatus: 401, + }), + }; + } + } catch { + // Normalized below. + } + + return { + ok: false, + error: failure( + "AUTH_INTEGRATION_FAILURE", + operation.operationId, + originalFailure.attemptCount, + { code: "AUTH_RECOVERY_FAILED" }, + ), + }; +} + +/** + * @param {string} kind + * @param {string} operationId + * @param {number} attempt + * @param {FailureDetails} [details] + * @returns {HttpFailure} + */ +function failure(kind, operationId, attempt, details = {}) { + const retryable = new Set([ + "NETWORK_UNREACHABLE", + "REQUEST_TIMEOUT", + "RATE_LIMITED", + "SERVER_FAILURE", + ]).has(kind); + const action = + kind === "AUTH_REQUIRED" + ? "reauth" + : retryable + ? "retry" + : kind === "REQUEST_ABORTED" + ? "none" + : "contact-support"; + + return Object.freeze({ + kind, + code: details.code ?? kind, + retryable, + operationId, + attemptCount: attempt + 1, + ...(details.httpStatus === undefined ? {} : { httpStatus: details.httpStatus }), + ...(details.requestId ? { requestId: details.requestId } : {}), + ...(details.traceId ? { traceId: details.traceId } : {}), + ...(details.retryAfterMs === undefined + ? {} + : { retryAfterMs: details.retryAfterMs }), + userMessageKey: `error.${kind.toLowerCase()}`, + action, + }); +} + +/** @param {number} status */ +function statusKind(status) { + if (status === 401) return "AUTH_REQUIRED"; + if (status === 403) return "FORBIDDEN"; + if (status === 404) return "NOT_FOUND"; + if (status === 409) return "CONFLICT"; + if (status === 422) return "VALIDATION_REJECTED"; + if (status === 429) return "RATE_LIMITED"; + if (status >= 500) return "SERVER_FAILURE"; + if (status >= 400) return "UNKNOWN_CLIENT_FAILURE"; + return "ENVELOPE_MISMATCH"; +} + +/** @param {unknown} envelope */ +function safeBackendCode(envelope) { + if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE"; + const error = /** @type {Record} */ (envelope).error; + if (!error || typeof error !== "object") return "HTTP_FAILURE"; + const code = /** @type {Record} */ (error).code; + return typeof code === "string" ? code : "HTTP_FAILURE"; +} + +/** @param {unknown} meta @returns {Record} */ +function safeMeta(meta) { + if (!meta || typeof meta !== "object") return {}; + const metaRecord = /** @type {Record} */ (meta); + return { + ...(typeof metaRecord.requestId === "string" + ? { requestId: metaRecord.requestId } + : {}), + ...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}), + }; +} + +/** @param {string} value */ +function parseRetryAfterHeader(value) { + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined; +} diff --git a/src/adapters/http/retry-policy.js b/src/adapters/http/retry-policy.js new file mode 100644 index 0000000..ecf787e --- /dev/null +++ b/src/adapters/http/retry-policy.js @@ -0,0 +1,79 @@ +const retryKinds = new Set([ + "NETWORK_UNREACHABLE", + "REQUEST_TIMEOUT", + "RATE_LIMITED", + "SERVER_FAILURE", +]); + +/** + * @param {number} retryIndex + * @param {() => number} [random] + * @param {number} [baseDelayMs] + * @param {number} [maxDelayMs] + */ +export function calculateBackoff( + retryIndex, + random = Math.random, + baseDelayMs = 250, + maxDelayMs = 2_000, +) { + return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random(); +} + +/** @param {string | null | undefined} value @param {number} [now] */ +export function parseRetryAfter(value, now = Date.now()) { + if (!value) return null; + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return seconds < 0 ? null : seconds * 1_000; + } + + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return null; + return Math.max(0, timestamp - now); +} + +/** + * @param {{ idempotency: "safe" | "keyed" | "none" }} operation + * @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure + * @param {number} retryCount + * @param {number} [maxRetries] + */ +export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { + if (retryCount >= maxRetries) return false; + if (!retryKinds.has(failure.kind)) return false; + if ( + failure.kind === "SERVER_FAILURE" && + failure.httpStatus !== undefined && + ![502, 503, 504].includes(failure.httpStatus) + ) { + return false; + } + if ( + failure.kind === "RATE_LIMITED" && + typeof failure.retryAfterMs === "number" && + failure.retryAfterMs > 30_000 + ) { + return false; + } + return operation.idempotency === "safe" || operation.idempotency === "keyed"; +} + +/** + * @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure + * @param {number} retryIndex + * @param {() => number} [random] + * @param {number} [now] + */ +export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) { + const localBackoff = calculateBackoff(retryIndex, random); + if (failure.kind !== "RATE_LIMITED") return localBackoff; + + const retryAfterMs = + typeof failure.retryAfterMs === "number" + ? failure.retryAfterMs + : parseRetryAfter(failure.retryAfter, now); + + return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs); +} diff --git a/src/contracts/api-operations.js b/src/contracts/api-operations.js new file mode 100644 index 0000000..08c6558 --- /dev/null +++ b/src/contracts/api-operations.js @@ -0,0 +1,51 @@ +/** + * @typedef {{ + * method: string, + * path: string, + * operationId: string, + * auth: "none" | "external-session", + * timeoutMs: number, + * idempotency: "safe" | "keyed" | "none", + * requestSchema: string, + * responseSchema: string, + * owner: string + * }} ApiOperation + */ + +/** @param {ApiOperation} definition */ +const operation = (definition) => Object.freeze(definition); + +export const API_OPERATIONS = Object.freeze({ + LIST_SAMPLE_RESOURCES: operation({ + method: "GET", + path: "/api/sample/resources", + operationId: "LIST_SAMPLE_RESOURCES", + auth: "external-session", + timeoutMs: 10_000, + idempotency: "safe", + requestSchema: "SampleResourceListQuery", + responseSchema: "SampleResourceListPayload", + owner: "feature-sample-feature-slice-contract-fixture", + }), + CREATE_SAMPLE_RESOURCE: operation({ + method: "POST", + path: "/api/sample/resources", + operationId: "CREATE_SAMPLE_RESOURCE", + auth: "external-session", + timeoutMs: 10_000, + idempotency: "keyed", + requestSchema: "CreateSampleResourceCommand", + responseSchema: "SampleResourcePayload", + owner: "feature-sample-feature-slice-contract-fixture", + }), +}); + +/** @param {string} operationId */ +export function getApiOperation(operationId) { + const registry = /** @type {Record} */ (API_OPERATIONS); + const selected = registry[operationId]; + if (!selected) { + throw new Error(`Unregistered API operation: ${operationId}`); + } + return selected; +} diff --git a/tests/integration/http-client.test.js b/tests/integration/http-client.test.js new file mode 100644 index 0000000..28c1dd0 --- /dev/null +++ b/tests/integration/http-client.test.js @@ -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("raw body", { 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"); + }); +}); diff --git a/tests/unit/retry-policy.test.js b/tests/unit/retry-policy.test.js new file mode 100644 index 0000000..de7cd34 --- /dev/null +++ b/tests/unit/retry-policy.test.js @@ -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); + }); +});