408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
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<string, string> } |
|
|
* { 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<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);
|
|
|
|
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<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,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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<string, unknown>} */ (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<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;
|
|
}
|