Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c36cd978c | ||
|
|
b193feeddc |
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user