import { systemClock } from "../../application/ports/clock-port.js"; import { getApiOperation } from "../../contracts/api-operations.js"; import { createFailure as failure, kindForStatus as statusKind, } from "../../contracts/errors.js"; import { retryDelay, shouldRetry } from "./retry-policy.js"; import { validateEnvelope, validateOperationPayload, validateOperationRequest, } from "./schema-registry.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 */ /** * @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 ?? validateOperationPayload; 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 (outcome.error.httpStatus === 401 && recoveryUsed) { authSession.onUnauthenticated(); return outcome; } 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); if (input.body !== undefined) { const requestValidation = validateOperationRequest( operation.requestSchema, input.body, ); if (!requestValidation.success) { return { ok: false, error: failure("VALIDATION_REJECTED", operation.operationId, attempt, { code: "REQUEST_SCHEMA_INVALID", }), }; } } 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, }), }; } const envelopeValidation = validateEnvelope(envelope); if (!envelopeValidation.success) { return { ok: false, error: failure( response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status), operation.operationId, attempt, { code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE", httpStatus: response.status, }, ), }; } const envelopeRecord = /** @type {Record} */ (envelopeValidation.data); 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} */ /** @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; }