Compare commits

..
5 changed files with 686 additions and 0 deletions
+438
View File
@@ -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<string, string> } |
* { 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<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal },
* attempt: number,
* idempotencyKey?: string
* }} context
* @returns {Promise<HttpResult>}
*/
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<HttpResult>}
*/
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<string, unknown>} */ (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<string, unknown>} */ (envelope).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = /** @type {Record<string, unknown>} */ (error).code;
return typeof code === "string" ? code : "HTTP_FAILURE";
}
/** @param {unknown} meta @returns {Record<string, string>} */
function safeMeta(meta) {
if (!meta || typeof meta !== "object") return {};
const metaRecord = /** @type {Record<string, unknown>} */ (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;
}
+79
View File
@@ -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);
}
+51
View File
@@ -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<string, ApiOperation>} */ (API_OPERATIONS);
const selected = registry[operationId];
if (!selected) {
throw new Error(`Unregistered API operation: ${operationId}`);
}
return selected;
}
+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);
});
});