feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
+50
View File
@@ -0,0 +1,50 @@
export type BoundedJsonResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>;
export async function readBoundedJson(
response: Response,
maxBytes: number,
): Promise<BoundedJsonResult> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
if (!response.body) return { ok: false, code: "MALFORMED_JSON" };
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > maxBytes) {
await reader.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
chunks.push(next.value);
}
} catch {
return { ok: false, code: "MALFORMED_JSON" };
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return {
ok: true,
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
};
} catch {
return { ok: false, code: "MALFORMED_JSON" };
}
}
-589
View File
@@ -1,589 +0,0 @@
import { systemClock } from "../platform/system-clock.js";
import { getApiOperation } from "../../contracts/api-operations.js";
import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.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 {import("../../contracts/errors.js").ApiFailure} HttpFailure */
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
/**
* @typedef {{
* setTimeout(callback: () => void, milliseconds: number): unknown,
* clearTimeout(handle: unknown): void
* }} Scheduler
*/
/**
* @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 },
* validateRequest?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* correlationIdFactory?: () => 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 validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const diagnostics = dependencies.diagnostics;
const telemetry = dependencies.telemetry;
const correlationIdFactory =
dependencies.correlationIdFactory ??
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
const scheduler =
dependencies.scheduler ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/**
* @param {string | OperationRequestInput} request
* @param {{
* body?: unknown,
* routeId?: string,
* pathParams?: Record<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal,
* idempotencyKey?: string,
* correlationId?: string
* }} [legacyInput]
* @returns {Promise<HttpResult>}
*/
async function execute(request, legacyInput = {}) {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
correlationId: legacyInput.correlationId,
}
: request;
const operation = selectOperation(input.operationId);
const startedAt = clock.now();
const correlationId = input.correlationId ?? correlationIdFactory();
/**
* @param {HttpResult} outcome
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
*/
function finalize(outcome, outcomeKind) {
const error = outcome.ok ? undefined : outcome.error;
const context = {
route_id: input.routeId,
operation_id: input.operationId,
correlation_id: correlationId,
outcome: outcomeKind,
error_kind: error?.kind ?? "NONE",
http_status_group: statusGroup(error?.httpStatus),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? retryCount + 1,
),
duration_bucket: durationBucket(clock.now() - startedAt),
};
try {
diagnostics?.record({
level: error ? "warn" : "info",
eventId: "http.request.completed",
context,
});
} catch {
// Diagnostics cannot change the HTTP result.
}
if (error && outcomeKind !== "aborted") {
try {
telemetry?.emit("api.request.failed", {
error_kind: context.error_kind,
http_status_group: context.http_status_group,
attempt_count_bucket: context.attempt_count_bucket,
route_id: context.route_id,
operation_id: context.operation_id,
duration_bucket: context.duration_bucket,
});
} catch {
// Telemetry cannot change the HTTP result.
}
}
return outcome;
}
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 finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
const recovered = await recoverSession(
authSession,
operation,
outcome.error,
);
if (!recovered.ok) return finalize(recovered, "failed");
if (operation.idempotency === "none") {
return finalize(
{
ok: false,
error: {
...outcome.error,
retryable: false,
action: "retry",
},
},
"failed",
);
}
continue;
}
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return finalize(
outcome,
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
);
}
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
retryCount += 1;
try {
await clock.sleep(delay, input.signal);
} catch {
return finalize(
{
ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
},
"aborted",
);
}
}
}
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: OperationRequestInput,
* attempt: number,
* idempotencyKey?: string
* }} context
* @returns {Promise<HttpResult>}
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
/** @type {unknown} */
let parsedSearch = {};
let parsedBody;
const requestValue =
operation.requestSource === "search"
? input.searchParams ?? {}
: operation.requestSource === "body"
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateRequest(
operation.requestSchema,
requestValue,
);
if (!requestValidation.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: "REQUEST_SCHEMA_INVALID",
}),
};
}
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
}
const target = buildRequestTarget(
dependencies.baseUrl,
operation,
input.pathParams,
parsedSearch,
);
if (!target.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs ?? defaultTimeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
const headers = new Headers({ Accept: "application/json" });
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
let request = new Request(target.url, {
method: operation.method,
headers,
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
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,
mapPayload,
);
} 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 {
scheduler.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
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
* @returns {Promise<HttpResult>}
*/
async function parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
) {
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,
}),
};
}
try {
return {
ok: true,
value: mapPayload(operation.operationId, payload.data),
meta: safeMeta(envelopeRecord.meta),
};
} catch (error) {
return {
ok: false,
error: normalizeUnknownFailure(error, {
operationId: operation.operationId,
attempt,
}),
};
}
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
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,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: 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;
}
+944
View File
@@ -0,0 +1,944 @@
import { systemClock } from "../platform/system-clock.ts";
import { getApiOperation } from "../../contracts/api-operations.ts";
import {
createFailure as failure,
kindForStatus as statusKind,
safeValidationIssues,
} from "../../contracts/errors.ts";
import { mapOperationPayload } from "./resource-mapper.ts";
import { retryDelay, shouldRetry } from "./retry-policy.ts";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.ts";
import { buildRequestTarget } from "./request-builder.ts";
import {
attemptBucket,
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.ts";
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
import type { ClockPort } from "../../application/ports/clock-port.ts";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
import type { ApiOperation } from "../../contracts/api-operations.ts";
import type { ApiFailure } from "../../contracts/errors.ts";
import type { OperationRequestInput } from "./request-builder.ts";
import { readBoundedJson } from "./bounded-json.ts";
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
import {
createRestProviderProfile,
resolveRestSecurityProfiles,
REST_AUTH_PROFILES,
REST_CSRF_PROFILES,
type RestAuthProfile,
type RestCsrfProfile,
type RestProviderProfile,
} from "../../contracts/rest-profiles.ts";
type HttpAuthSession = Pick<
AuthSessionPort,
"getState" | "credentialPatch" | "recover" | "onUnauthenticated"
>;
const noAuthSession: HttpAuthSession = Object.freeze({
getState: () => "integration-failed",
credentialPatch: async () => {
throw new TypeError("Auth session is not installed");
},
recover: async () => "no-session",
onUnauthenticated: () => {},
} satisfies HttpAuthSession);
export type HttpFailure = ApiFailure;
export type Scheduler = Readonly<{
setTimeout(callback: () => void, milliseconds: number): unknown;
clearTimeout(handle: unknown): void;
}>;
export type HttpResult =
| Readonly<{
ok: true;
value: unknown;
meta: Readonly<Record<string, string>>;
}>
| Readonly<{ ok: false; error: HttpFailure }>;
type SchemaValidator = (
schemaId: string,
value: unknown,
) =>
| Readonly<{ success: true; data: unknown }>
| Readonly<{ success: false }>;
export type HttpClientDependencies = Readonly<{
baseUrl: string;
fetcher?: typeof fetch;
authSession?: AuthSessionPort;
clock?: ClockPort;
random?: () => number;
validatePayload?: SchemaValidator;
validateRequest?: SchemaValidator;
validatePath?: SchemaValidator;
mapPayload?: (
operationId: string,
payload: unknown,
) => MappingResult<unknown>;
idempotencyKeyFactory?: () => string;
timeoutMs?: number;
maxRetryAttempts?: number;
scheduler?: Scheduler;
getOperation?: typeof getApiOperation;
diagnostics?: DiagnosticsPort;
telemetry?: TelemetryPort;
correlationIdFactory?: () => string;
providerProfile?: RestProviderProfile;
authProfiles?: Readonly<Record<string, RestAuthProfile>>;
csrfProfiles?: Readonly<Record<string, RestCsrfProfile>>;
maxCumulativeSleepMs?: number;
}>;
export type LegacyHttpInput = Readonly<{
body?: unknown;
routeId?: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
correlationId?: string;
}>;
export type HttpClient = Readonly<{
execute(
request: string | OperationRequestInput,
legacyInput?: LegacyHttpInput,
): Promise<HttpResult>;
}>;
export function createHttpClient(
dependencies: HttpClientDependencies,
): HttpClient {
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 validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const validatePath = dependencies.validatePath ?? validateRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
const maxCumulativeSleepMs =
dependencies.maxCumulativeSleepMs ?? defaultTimeoutMs;
const selectOperation = dependencies.getOperation ?? getApiOperation;
const diagnostics = dependencies.diagnostics;
const telemetry = dependencies.telemetry;
const correlationIdFactory =
dependencies.correlationIdFactory ??
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
const scheduler =
dependencies.scheduler ??
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler);
async function execute(
request: string | OperationRequestInput,
legacyInput: LegacyHttpInput = {},
): Promise<HttpResult> {
const input =
typeof request === "string"
? {
operationId: request,
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
pathParams: legacyInput.pathParams,
searchParams: legacyInput.searchParams,
body: legacyInput.body,
signal: legacyInput.signal,
idempotencyKey: legacyInput.idempotencyKey,
correlationId: legacyInput.correlationId,
}
: request;
const startedAt = clock.now();
let correlationId: string;
try {
correlationId = correlationIdValue(
input.correlationId ?? correlationIdFactory(),
);
} catch {
correlationId = "client-generated";
}
let operation: ApiOperation;
try {
operation = selectOperation(input.operationId);
} catch {
return {
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "OPERATION_NOT_REGISTERED",
}),
};
}
const totalDeadlineMs = operation.timeoutMs ?? defaultTimeoutMs;
const deadlineAt = startedAt + totalDeadlineMs;
let physicalAttemptCount = 0;
function finalize(
outcome: HttpResult,
outcomeKind: "success" | "recovered" | "failed" | "aborted",
): HttpResult {
const error = outcome.ok ? undefined : outcome.error;
const context = {
route_id: input.routeId,
operation_id: input.operationId,
correlation_id: correlationId,
outcome: outcomeKind,
error_kind: error?.kind ?? "NONE",
http_status_group: statusGroup(
error?.httpStatus ??
(outcome.ok ? Number(outcome.meta.httpStatus) : undefined),
),
attempt_count_bucket: attemptBucket(
error?.attemptCount ?? Math.max(1, physicalAttemptCount),
),
duration_bucket: durationBucket(clock.now() - startedAt),
};
try {
diagnostics?.record({
level: error ? "warn" : "info",
eventId: "http.request.completed",
context,
});
} catch {
// Diagnostics cannot change the HTTP result.
}
if (error && outcomeKind !== "aborted") {
try {
telemetry?.emit("api.request.failed", {
error_kind: context.error_kind,
http_status_group: context.http_status_group,
attempt_count_bucket: context.attempt_count_bucket,
route_id: context.route_id,
operation_id: context.operation_id,
duration_bucket: context.duration_bucket,
});
} catch {
// Telemetry cannot change the HTTP result.
}
}
return outcome;
}
let logicalIdempotencyKey: string | undefined;
try {
logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
} catch {
return finalize(
{
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
}),
},
"failed",
);
}
let retryCount = 0;
let recoveryUsed = false;
let cumulativeSleepMs = 0;
while (true) {
const attempt = physicalAttemptCount;
physicalAttemptCount += 1;
let outcome: HttpResult;
try {
outcome = await performAttempt({
operation,
input,
attempt,
idempotencyKey: logicalIdempotencyKey,
deadlineAt,
correlationId,
});
} catch {
outcome = {
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, attempt, {
code: "HTTP_EXECUTION_CONTRACT_VIOLATION",
}),
};
}
if (outcome.ok) {
return finalize(
outcome,
retryCount > 0 || recoveryUsed ? "recovered" : "success",
);
}
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
if (physicalAttemptCount >= maxRetryAttempts + 1) {
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
let recovered: Awaited<ReturnType<typeof recoverSession>>;
try {
recovered = await withinLogicalDeadline(
recoverSession(authSession, operation, outcome.error),
deadlineAt,
input.signal,
);
} catch (error) {
return finalize(
{
ok: false,
error: failure(
error instanceof LogicalDeadlineError
? "REQUEST_TIMEOUT"
: "REQUEST_ABORTED",
operation.operationId,
attempt,
{
code:
error instanceof LogicalDeadlineError
? "OPERATION_DEADLINE_EXCEEDED"
: "REQUEST_ABORTED",
},
),
},
error instanceof LogicalDeadlineError ? "failed" : "aborted",
);
}
if (!recovered.ok) return finalize(recovered, "failed");
if (operation.idempotency === "none") {
return finalize(
{
ok: false,
error: {
...outcome.error,
retryable: false,
action: "retry",
},
},
"failed",
);
}
continue;
}
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return finalize(
outcome,
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
);
}
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
retryCount += 1;
cumulativeSleepMs += delay;
if (
clock.now() + delay >= deadlineAt ||
cumulativeSleepMs > maxCumulativeSleepMs
) {
return finalize(
{
ok: false,
error: failure("REQUEST_TIMEOUT", input.operationId, attempt, {
code: "OPERATION_DEADLINE_EXCEEDED",
}),
},
"failed",
);
}
try {
await clock.sleep(delay, input.signal);
} catch {
return finalize(
{
ok: false,
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
},
"aborted",
);
}
}
}
async function performAttempt(
context: Readonly<{
operation: ApiOperation;
input: OperationRequestInput;
attempt: number;
idempotencyKey?: string;
deadlineAt: number;
correlationId: string;
}>,
): Promise<HttpResult> {
const {
operation,
input,
attempt,
idempotencyKey,
deadlineAt,
correlationId,
} = context;
if (clock.now() >= deadlineAt) {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "OPERATION_DEADLINE_EXCEEDED",
}),
};
}
let parsedSearch: unknown = {};
let parsedBody: unknown;
let parsedPath: Readonly<Record<string, string | number>> =
input.pathParams ?? {};
if (operation.pathSchema) {
const pathValidation = validatePath(
operation.pathSchema,
input.pathParams ?? {},
);
if (
!pathValidation.success ||
!isPathParameterRecord(pathValidation.data)
) {
return {
ok: false,
error: failure(
"VALIDATION_REJECTED",
operation.operationId,
attempt,
{ code: "PATH_SCHEMA_INVALID" },
),
};
}
parsedPath = pathValidation.data;
}
const requestValue =
operation.requestSource === "search"
? input.searchParams ?? {}
: operation.requestSource === "body"
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateRequest(
operation.requestSchema,
requestValue,
);
if (!requestValidation.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: "REQUEST_SCHEMA_INVALID",
}),
};
}
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
}
let target: ReturnType<typeof buildRequestTarget>;
let provider: RestProviderProfile | null = null;
let security:
| ReturnType<typeof resolveRestSecurityProfiles>
| undefined;
try {
provider =
dependencies.providerProfile ??
createRestProviderProfile(
operation.providerId ?? "LEGACY_API",
dependencies.baseUrl,
["omit", "same-origin"],
);
if (
operation.contractVersion === 2 &&
operation.providerId !== provider.providerId
) {
throw new TypeError("REST provider binding mismatch.");
}
if (operation.contractVersion === 2) {
security = resolveRestSecurityProfiles(
operation,
provider,
dependencies.authProfiles ?? REST_AUTH_PROFILES,
dependencies.csrfProfiles ?? REST_CSRF_PROFILES,
);
}
target = buildRequestTarget(
provider.baseUrl,
operation,
parsedPath,
parsedSearch,
);
} catch {
target = { success: false, code: "BASE_URL_INVALID" };
}
if (!target.success || !provider) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: target.success ? "BASE_URL_INVALID" : target.code,
}),
};
}
const controller = new AbortController();
let timedOut = false;
const remainingMs = Math.max(1, deadlineAt - clock.now());
const timeout = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, remainingMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
if (input.signal?.aborted) onExternalAbort();
try {
const headers = new Headers({
Accept: operation.responseMediaTypes?.join(", ") ?? "application/json",
"X-Correlation-ID": correlationIdValue(correlationId),
});
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (operation.auth === "external-session") {
let sessionState: ReturnType<HttpAuthSession["getState"]>;
try {
sessionState = authSession.getState();
} catch {
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
attempt,
{ code: "AUTH_STATE_FAILED" },
),
};
}
if (sessionState === "unauthenticated") {
return {
ok: false,
error: failure("AUTH_REQUIRED", operation.operationId, attempt, {
code: "AUTH_REQUIRED",
}),
};
}
if (sessionState !== "authenticated") {
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
attempt,
{ code: "AUTH_SESSION_UNAVAILABLE" },
),
};
}
try {
const patch = await authSession.credentialPatch({
origin: target.url.origin,
method: operation.method,
operationId: operation.operationId,
});
for (const [name, value] of Object.entries(patch.headers)) {
const normalized = name.toLowerCase();
const allowedHeaders =
security?.auth.allowedCredentialHeaders ??
(["authorization", "x-csrf-token"] as const);
if (!allowedHeaders.includes(normalized as never)) {
throw new TypeError("Credential patch contains a forbidden header");
}
headers.set(normalized, value);
}
} catch {
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
code: "AUTH_ATTACH_FAILED",
}),
};
}
}
const request = new Request(target.url, {
method: operation.method,
headers,
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal,
credentials: security?.auth.credentials ?? "same-origin",
cache: "no-store",
redirect: provider.redirect,
referrerPolicy: provider.referrerPolicy,
});
const response = await fetcher(request);
return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
clock.now(),
);
} 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 {
scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
return Object.freeze({ execute });
function withinLogicalDeadline<Value>(
promise: Promise<Value>,
deadlineAt: number,
externalSignal: AbortSignal | undefined,
): Promise<Value> {
const remaining = deadlineAt - clock.now();
if (remaining <= 0) return Promise.reject(new LogicalDeadlineError());
return new Promise<Value>((resolve, reject) => {
let settled = false;
const timeout = scheduler.setTimeout(
() => settle(() => reject(new LogicalDeadlineError())),
remaining,
);
const onAbort = () =>
settle(() => reject(new DOMException("Aborted", "AbortError")));
externalSignal?.addEventListener("abort", onAbort, { once: true });
const settle = (complete: () => void) => {
if (settled) return;
settled = true;
scheduler.clearTimeout(timeout);
externalSignal?.removeEventListener("abort", onAbort);
complete();
};
if (externalSignal?.aborted) {
onAbort();
return;
}
promise.then(
(value) => settle(() => resolve(value)),
(error: unknown) => settle(() => reject(error)),
);
});
}
}
class LogicalDeadlineError extends Error {}
async function parseResponse(
response: Response,
operation: ApiOperation,
attempt: number,
validatePayload: SchemaValidator,
mapPayload: (
operationId: string,
payload: unknown,
) => MappingResult<unknown>,
now: number,
): Promise<HttpResult> {
const contentType = mediaType(response.headers.get("content-type"));
const acceptedMedia = operation.responseMediaTypes ?? ["application/json"];
if (!contentType || !acceptedMedia.includes(contentType)) {
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
code: "CONTENT_TYPE_MISMATCH",
httpStatus: response.status,
}),
};
}
const decoded = await readBoundedJson(
response,
operation.maxResponseBytes ?? 1_048_576,
);
if (!decoded.ok) {
return {
ok: false,
error: failure(decoded.code, operation.operationId, attempt, {
code: decoded.code,
httpStatus: response.status,
}),
};
}
const envelope = decoded.value;
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 = envelopeValidation.data as Record<string, unknown>;
const successStatus = operation.successStatuses
? operation.successStatuses.includes(response.status)
: response.ok;
if (successStatus && 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,
}),
};
}
try {
const mapped = mapPayload(operation.operationId, payload.data);
if (!mapped.ok) {
return {
ok: false,
error: failure(
"MAPPING_CONTRACT_VIOLATION",
operation.operationId,
attempt,
{
code: mapped.code,
httpStatus: response.status,
},
),
};
}
return {
ok: true,
value: mapped.value,
meta: {
...safeMeta(envelopeRecord.meta),
httpStatus: String(response.status),
},
};
} catch {
return {
ok: false,
error: failure(
"MAPPING_CONTRACT_VIOLATION",
operation.operationId,
attempt,
{
code: "MAPPING_CONTRACT_VIOLATION",
httpStatus: response.status,
},
),
};
}
}
if (successStatus !== response.ok || (successStatus && envelopeRecord.success !== true)) {
return {
ok: false,
error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, {
code: "STATUS_ENVELOPE_MISMATCH",
httpStatus: response.status,
}),
};
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? (envelopeRecord.error as Record<string, unknown>)
: {};
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, now)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
async function recoverSession(
authSession: HttpAuthSession,
operation: ApiOperation,
originalFailure: HttpFailure,
): Promise<
Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }>
> {
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 - 1, {
code: "AUTH_REQUIRED",
httpStatus: 401,
}),
};
}
} catch {
// Normalized below.
}
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
originalFailure.attemptCount - 1,
{ code: "AUTH_RECOVERY_FAILED" },
),
};
}
function safeBackendCode(envelope: unknown): string {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
const error = (envelope as Record<string, unknown>).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = (error as Record<string, unknown>).code;
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code)
? code
: "HTTP_FAILURE";
}
function safeMeta(meta: unknown): Record<string, string> {
if (!meta || typeof meta !== "object") return {};
const metaRecord = meta as Record<string, unknown>;
return {
...(typeof metaRecord.requestId === "string"
? safeIdentifier(metaRecord.requestId, "requestId")
: {}),
...(typeof metaRecord.traceId === "string"
? safeIdentifier(metaRecord.traceId, "traceId")
: {}),
};
}
function parseRetryAfterHeader(value: string, now: number): number | undefined {
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 - now) : undefined;
}
function safeIdentifier(
value: string,
property: "requestId" | "traceId",
): Record<string, string> {
return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? { [property]: value } : {};
}
function mediaType(value: string | null): string | null {
if (!value) return null;
const selected = value.split(";", 1)[0]?.trim().toLowerCase();
return selected && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(selected)
? selected
: null;
}
function correlationIdValue(value: string | undefined): string {
return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value)
? value
: "client-generated";
}
function isPathParameterRecord(
value: unknown,
): value is Readonly<Record<string, string | number>> {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.values(value).every(
(item) => typeof item === "string" || typeof item === "number",
)
);
}
+64 -4
View File
@@ -1,4 +1,4 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
import type { ApiOperation } from "../../contracts/api-operations.ts";
export type OperationRequestInput = Readonly<{
operationId: string;
@@ -15,7 +15,12 @@ export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
code:
| "BASE_URL_INVALID"
| "PATH_PARAMETER_MISSING"
| "PATH_PARAMETER_UNEXPECTED"
| "PATH_PARAMETER_INVALID"
| "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
@@ -26,7 +31,35 @@ export function buildRequestTarget(
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let base: URL;
try {
base = new URL(baseUrl);
} catch {
return { success: false, code: "BASE_URL_INVALID" };
}
if (
(base.protocol !== "https:" &&
!(
base.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
)) ||
base.username ||
base.password ||
base.search ||
base.hash
) {
return { success: false, code: "BASE_URL_INVALID" };
}
const placeholders = new Set<string>();
for (const match of operation.path.matchAll(pathParameterPattern)) {
placeholders.add(match[1] ?? match[2] ?? "");
}
if (Object.keys(pathParams).some((key) => !placeholders.has(key))) {
return { success: false, code: "PATH_PARAMETER_UNEXPECTED" };
}
let missingPathParameter = false;
let invalidPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
@@ -36,12 +69,27 @@ export function buildRequestTarget(
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
const serialized = String(value);
if (
serialized.length === 0 ||
serialized.length > 512 ||
[...serialized].some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
) {
invalidPathParameter = true;
return "";
}
return encodeURIComponent(serialized);
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (invalidPathParameter) {
return { success: false, code: "PATH_PARAMETER_INVALID" };
}
if (
parsedSearch === null ||
@@ -51,7 +99,12 @@ export function buildRequestTarget(
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
const basePrefix = base.pathname.endsWith("/")
? base.pathname
: `${base.pathname}/`;
const relativePath = pathname.replace(/^\/+/, "");
base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/");
const url = base;
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
@@ -70,5 +123,12 @@ export function buildRequestTarget(
url.searchParams.append(key, String(item));
}
}
if (
operation.maxEncodedSearchBytes !== undefined &&
new TextEncoder().encode(url.search).byteLength >
operation.maxEncodedSearchBytes
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
return { success: true, url };
}
-5
View File
@@ -1,5 +0,0 @@
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
void payload;
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
+11
View File
@@ -0,0 +1,11 @@
import {
mappingFailure,
type MappingResult,
} from "../../contracts/boundary-mapper.ts";
export function mapOperationPayload(
_operationId: string,
_payload: unknown,
): MappingResult<never> {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
@@ -1,27 +1,23 @@
const retryKinds = new Set([
const retryKinds: ReadonlySet<string> = 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,
retryIndex: number,
random = Math.random,
baseDelayMs = 250,
maxDelayMs = 2_000,
) {
): number {
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
}
/** @param {string | null | undefined} value @param {number} [now] */
export function parseRetryAfter(value, now = Date.now()) {
export function parseRetryAfter(
value: string | null | undefined,
now = Date.now(),
): number | null {
if (!value) return null;
const seconds = Number(value);
@@ -34,13 +30,24 @@ export function parseRetryAfter(value, now = Date.now()) {
return Math.max(0, timestamp - now);
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount
* @param {number} [maxRetries]
*/
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
export type RetryOperation = Readonly<{
idempotency: "safe" | "keyed" | "none";
retry?: "runtime" | "never";
}>;
export type RetryFailure = Readonly<{
kind: string;
retryAfterMs?: number;
retryAfter?: string;
httpStatus?: number;
}>;
export function shouldRetry(
operation: RetryOperation,
failure: RetryFailure,
retryCount: number,
maxRetries = 2,
): boolean {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
@@ -61,13 +68,12 @@ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
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()) {
export function retryDelay(
failure: RetryFailure,
retryIndex: number,
random = Math.random,
now = Date.now(),
): number {
const localBackoff = calculateBackoff(retryIndex, random);
if (failure.kind !== "RATE_LIMITED") return localBackoff;
-92
View File
@@ -1,92 +0,0 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().min(1),
traceId: z.string().min(1),
correlationId: z.string().min(1).optional(),
})
.passthrough();
export const successEnvelopeSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
meta: metaSchema,
})
.strict();
export const failureEnvelopeSchema = z
.object({
success: z.literal(false),
error: z
.object({
code: z.string().min(1),
category: z.string().min(1).optional(),
message: z.string().optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
/** @param {unknown} value */
export function validateEnvelope(value) {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationPayload(schemaId, value) {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationRequest(schemaId, value) {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId */
function missingSchema(schemaId) {
return {
success: /** @type {false} */ (false),
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
/**
* @param {{ success: true, data: unknown } |
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
*/
function projectResult(result) {
if (result.success) {
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
traceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
correlationId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(),
})
.strip();
export const successEnvelopeSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
meta: metaSchema,
})
.strict();
export const failureEnvelopeSchema = z
.object({
success: z.literal(false),
error: z
.object({
code: z.string().regex(/^[A-Z0-9_]{1,64}$/),
category: z.string().min(1).max(64).optional(),
message: z.string().max(1_024).optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const payloadSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
Object.freeze({});
const requestSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
Object.freeze({});
export type SchemaIssue = Readonly<{
path: string;
code: string;
schemaId?: string;
}>;
export type SchemaValidationResult =
| Readonly<{ success: true; data: unknown }>
| Readonly<{ success: false; issues: readonly SchemaIssue[] }>;
export function validateEnvelope(value: unknown): SchemaValidationResult {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
export function validateOperationPayload(
schemaId: string,
value: unknown,
): SchemaValidationResult {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
export function validateOperationRequest(
schemaId: string,
value: unknown,
): SchemaValidationResult {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
function missingSchema(schemaId: string): SchemaValidationResult {
return {
success: false,
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
function projectResult(
result:
| Readonly<{ success: true; data: unknown }>
| Readonly<{
success: false;
error: Readonly<{
issues: readonly Readonly<{
path: readonly PropertyKey[];
code: string;
}>[];
}>;
}>,
): SchemaValidationResult {
if (result.success) {
return {
success: true,
data: structuredClone(result.data),
};
}
return {
success: false,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}