1227 lines
35 KiB
TypeScript
1227 lines
35 KiB
TypeScript
import {
|
|
HTTP_EXECUTION_CEILINGS,
|
|
invokeValidator,
|
|
type InstalledHttpContract,
|
|
} from "../../contracts/external-contract-runtime.ts";
|
|
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
|
import {
|
|
defineMutationIntent,
|
|
MUTATION_INTENT_BOUNDS,
|
|
type MutationIntent,
|
|
} from "../../contracts/mutation-intent.ts";
|
|
import {
|
|
decodeJsonBytes,
|
|
isEffectivelyEmpty,
|
|
isJsonMediaType,
|
|
probeForbiddenBody,
|
|
readBoundedBytes,
|
|
} from "./bounded-body-reader.ts";
|
|
import {
|
|
checkFinalInvariants,
|
|
projectRequest,
|
|
type CredentialPatchOutcome,
|
|
} from "./http-contract-bridge.ts";
|
|
import {
|
|
certaintyForAbandonedAttempt,
|
|
classifyProblemEffect,
|
|
type PhysicalAttemptState,
|
|
} from "./http-effect-certainty.ts";
|
|
import { parseRetryAfter } from "./retry-policy.ts";
|
|
|
|
/**
|
|
* §7–§8. Descriptor-driven HTTP execution.
|
|
*
|
|
* This is not a general purpose HTTP client. A caller passes an installed
|
|
* contract and typed input; the runtime owns bounds, the total deadline, the
|
|
* single retry authority and the effect-certainty verdict.
|
|
*/
|
|
|
|
export type SafeResponseMetadata = Readonly<{
|
|
status: number;
|
|
correlationReference?: string;
|
|
retryAfterMs?: number;
|
|
validatorToken?: string;
|
|
}>;
|
|
|
|
export type HttpContractViolationKind =
|
|
| "MISSING_IDEMPOTENCY_KEY"
|
|
| "UNEXPECTED_IDEMPOTENCY_KEY"
|
|
| "UNEXPECTED_STATUS"
|
|
| "UNEXPECTED_EMPTY_BODY"
|
|
| "UNEXPECTED_BODY"
|
|
| "CONTENT_TYPE_MISMATCH"
|
|
| "RESPONSE_TOO_LARGE"
|
|
| "UTF8_INVALID"
|
|
| "JSON_INVALID"
|
|
| "SUCCESS_SCHEMA_INVALID"
|
|
| "PROBLEM_SCHEMA_INVALID"
|
|
| "VALIDATOR_RUNTIME_FAILURE"
|
|
| "MAPPING_CONTRACT_VIOLATION"
|
|
| "FINAL_REQUEST_INVARIANT_FAILED"
|
|
| "SCOPE_FENCED";
|
|
|
|
export type HttpContractViolation = Readonly<{
|
|
kind: HttpContractViolationKind;
|
|
operation: "REQUEST" | "RESPONSE" | "VALIDATION" | "MAPPING";
|
|
}>;
|
|
|
|
export type HttpEffectCertainty =
|
|
| "NOT_APPLICABLE"
|
|
| "NOT_STARTED"
|
|
| "NOT_APPLIED"
|
|
| "APPLIED_CONFIRMED"
|
|
| "MAYBE_APPLIED";
|
|
|
|
export type HttpTransportFailure = Readonly<{
|
|
kind:
|
|
| "DNS_OR_CONNECT_FAILURE"
|
|
| "TLS_OR_NETWORK_FAILURE"
|
|
| "NETWORK_FAILURE"
|
|
| "TIMEOUT"
|
|
| "RESPONSE_STREAM_FAILURE"
|
|
| "ABORTED_BY_SCOPE"
|
|
| "ABORTED_BY_CALLER"
|
|
| "OVERLOADED_BEFORE_SEND";
|
|
retryable: boolean;
|
|
}>;
|
|
|
|
export type HttpExecutionOutcome<Value, Problem> =
|
|
| Readonly<{
|
|
kind: "SUCCESS";
|
|
value: Value;
|
|
metadata: SafeResponseMetadata;
|
|
effect: "NOT_APPLICABLE" | "APPLIED_CONFIRMED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "PROBLEM";
|
|
problem: Problem;
|
|
metadata: SafeResponseMetadata;
|
|
effect: "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "UNAUTHENTICATED";
|
|
effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "FORBIDDEN";
|
|
effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "RATE_LIMITED";
|
|
retryAfterMs?: number;
|
|
effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "CONTRACT_VIOLATION";
|
|
violation: HttpContractViolation;
|
|
effect: HttpEffectCertainty;
|
|
}>
|
|
| Readonly<{
|
|
kind: "TRANSPORT_FAILURE";
|
|
failure: HttpTransportFailure;
|
|
effect: "NOT_STARTED" | "MAYBE_APPLIED";
|
|
}>
|
|
| Readonly<{
|
|
kind: "CANCELLED";
|
|
effect: "NOT_STARTED" | "MAYBE_APPLIED";
|
|
}>;
|
|
|
|
export type CancellationOwner =
|
|
| "CALLER"
|
|
| "ROUTE_TRANSITION"
|
|
| "SCOPE_FENCE"
|
|
| "APPLICATION_SHUTDOWN"
|
|
| "DEADLINE";
|
|
|
|
export interface HttpExecutionContext {
|
|
readonly signal?: AbortSignal;
|
|
readonly scope: CacheScopeSnapshot;
|
|
readonly intent?: MutationIntent;
|
|
}
|
|
|
|
export interface ContractHttpExecutor {
|
|
execute<Input, WireOutput, Problem>(
|
|
operation: InstalledHttpContract<Input, WireOutput, Problem>,
|
|
input: Input,
|
|
context: HttpExecutionContext,
|
|
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
|
}
|
|
|
|
export type HttpExecutionObservation = Readonly<{
|
|
diagnosticsOperation: string;
|
|
outcome: string;
|
|
attempts: number;
|
|
certainty: string;
|
|
}>;
|
|
|
|
export type ContractHttpExecutorDependencies = Readonly<{
|
|
baseUrl: string;
|
|
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
|
|
maxRetryAttempts: number;
|
|
attachCredentials(
|
|
operation: Readonly<{
|
|
operationId: string;
|
|
authProfileId: string;
|
|
method: string;
|
|
}>,
|
|
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
|
|
fetcher?: typeof fetch;
|
|
/** Adapter seam for the common bounded response reader. */
|
|
readBoundedResponseBytes?: typeof readBoundedBytes;
|
|
monotonicNow?: () => number;
|
|
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
random?: () => number;
|
|
observe?: (observation: HttpExecutionObservation) => void;
|
|
}>;
|
|
|
|
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
|
|
408, 425, 429, 502, 503, 504,
|
|
]);
|
|
|
|
const RETRY_BASE_DELAY_MS = 250;
|
|
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
|
|
const RETRY_AFTER_CEILING_MS = 5_000;
|
|
|
|
type MutationIntentValidation =
|
|
| Readonly<{ ok: true; intent?: MutationIntent }>
|
|
| Readonly<{
|
|
ok: false;
|
|
violation: "MISSING_IDEMPOTENCY_KEY" | "UNEXPECTED_IDEMPOTENCY_KEY";
|
|
}>;
|
|
|
|
function validateMutationIntent(
|
|
contract: Readonly<{
|
|
operationId: string;
|
|
retrySemantics: "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER";
|
|
commandEffect: unknown | null;
|
|
}>,
|
|
intent: MutationIntent | undefined,
|
|
): MutationIntentValidation {
|
|
const isCommand = contract.commandEffect !== null;
|
|
const requiresKey = contract.retrySemantics === "KEYED";
|
|
if (!isCommand) {
|
|
return intent === undefined
|
|
? Object.freeze({ ok: true })
|
|
: Object.freeze({
|
|
ok: false,
|
|
violation: "UNEXPECTED_IDEMPOTENCY_KEY",
|
|
});
|
|
}
|
|
if (intent === undefined) {
|
|
return requiresKey
|
|
? Object.freeze({ ok: false, violation: "MISSING_IDEMPOTENCY_KEY" })
|
|
: Object.freeze({ ok: true });
|
|
}
|
|
|
|
let validated: MutationIntent;
|
|
try {
|
|
validated = defineMutationIntent(intent);
|
|
} catch {
|
|
return Object.freeze({
|
|
ok: false,
|
|
violation: requiresKey
|
|
? "MISSING_IDEMPOTENCY_KEY"
|
|
: "UNEXPECTED_IDEMPOTENCY_KEY",
|
|
});
|
|
}
|
|
if (validated.operationId !== contract.operationId) {
|
|
return Object.freeze({
|
|
ok: false,
|
|
violation: requiresKey
|
|
? "MISSING_IDEMPOTENCY_KEY"
|
|
: "UNEXPECTED_IDEMPOTENCY_KEY",
|
|
});
|
|
}
|
|
|
|
const key = validated.idempotencyKey;
|
|
if (requiresKey && !validIdempotencyKey(key)) {
|
|
return Object.freeze({
|
|
ok: false,
|
|
violation: "MISSING_IDEMPOTENCY_KEY",
|
|
});
|
|
}
|
|
if (!requiresKey && key !== undefined) {
|
|
return Object.freeze({
|
|
ok: false,
|
|
violation: "UNEXPECTED_IDEMPOTENCY_KEY",
|
|
});
|
|
}
|
|
return Object.freeze({ ok: true, intent: validated });
|
|
}
|
|
|
|
const UTF8 = new TextEncoder();
|
|
|
|
function validIdempotencyKey(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.trim().length > 0 &&
|
|
UTF8.encode(value).byteLength <=
|
|
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes &&
|
|
!hasControlCharacter(value)
|
|
);
|
|
}
|
|
|
|
function hasControlCharacter(value: string): boolean {
|
|
for (const character of value) {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
if (
|
|
codePoint <= 0x1f ||
|
|
(codePoint >= 0x7f && codePoint <= 0x9f)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function createContractHttpExecutor(
|
|
dependencies: ContractHttpExecutorDependencies,
|
|
): ContractHttpExecutor {
|
|
const fetcher = dependencies.fetcher ?? fetch;
|
|
const readResponseBytes =
|
|
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
|
|
const now = dependencies.monotonicNow ?? (() => performance.now());
|
|
const random = dependencies.random ?? Math.random;
|
|
const sleep =
|
|
dependencies.sleep ??
|
|
((ms: number, signal: AbortSignal) =>
|
|
new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, ms);
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
},
|
|
{ once: true },
|
|
);
|
|
}));
|
|
|
|
async function execute<Input, WireOutput, Problem>(
|
|
operation: InstalledHttpContract<Input, WireOutput, Problem>,
|
|
input: Input,
|
|
context: HttpExecutionContext,
|
|
): Promise<HttpExecutionOutcome<WireOutput, Problem>> {
|
|
const contract = operation.contract;
|
|
const policy = operation.frontend;
|
|
const isCommand = contract.commandEffect !== null;
|
|
|
|
// §8.5. One monotonic deadline covers credential resolution, encoding,
|
|
// backoff, every physical attempt, body read and validation.
|
|
const deadlineAt = now() + policy.totalDeadlineMs;
|
|
const remaining = () => deadlineAt - now();
|
|
|
|
let attemptState: PhysicalAttemptState = "PREPARING";
|
|
let attempts = 0;
|
|
let terminalCancellation: CancellationOwner | null = null;
|
|
const lifetimeController = new AbortController();
|
|
const forwardCallerToLifetime = () => {
|
|
terminalCancellation ??= "CALLER";
|
|
lifetimeController.abort();
|
|
};
|
|
const forwardScopeToLifetime = () => {
|
|
terminalCancellation ??= "SCOPE_FENCE";
|
|
lifetimeController.abort();
|
|
};
|
|
const callerSignal =
|
|
context.signal === context.scope.signal ? undefined : context.signal;
|
|
if (callerSignal?.aborted) forwardCallerToLifetime();
|
|
else {
|
|
callerSignal?.addEventListener("abort", forwardCallerToLifetime, {
|
|
once: true,
|
|
});
|
|
}
|
|
if (context.scope.signal.aborted) forwardScopeToLifetime();
|
|
else {
|
|
context.scope.signal.addEventListener("abort", forwardScopeToLifetime, {
|
|
once: true,
|
|
});
|
|
}
|
|
const lifetimeDeadlineTimer = setTimeout(() => {
|
|
terminalCancellation ??= "DEADLINE";
|
|
lifetimeController.abort();
|
|
}, policy.totalDeadlineMs);
|
|
let lifetimeDisposed = false;
|
|
const disposeLifetime = () => {
|
|
if (lifetimeDisposed) return;
|
|
lifetimeDisposed = true;
|
|
clearTimeout(lifetimeDeadlineTimer);
|
|
callerSignal?.removeEventListener("abort", forwardCallerToLifetime);
|
|
context.scope.signal.removeEventListener("abort", forwardScopeToLifetime);
|
|
lifetimeController.abort();
|
|
};
|
|
|
|
const finish = (
|
|
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
|
certainty: string,
|
|
): HttpExecutionOutcome<WireOutput, Problem> => {
|
|
disposeLifetime();
|
|
try {
|
|
dependencies.observe?.({
|
|
diagnosticsOperation: policy.diagnosticsOperation,
|
|
outcome: outcome.kind,
|
|
attempts,
|
|
certainty,
|
|
});
|
|
} catch {
|
|
// Observation is outside the execution authority.
|
|
}
|
|
return outcome;
|
|
};
|
|
|
|
try {
|
|
|
|
// §7.4 step 1-2: capture the scope and verify it is still current.
|
|
if (!context.scope.isCurrent()) {
|
|
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
|
}
|
|
|
|
const intentValidation = validateMutationIntent(contract, context.intent);
|
|
if (!intentValidation.ok) {
|
|
return finish(
|
|
violation(intentValidation.violation, "REQUEST", "NOT_STARTED"),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
|
|
const validated = invokeValidator(contract.inputValidator, input);
|
|
if (validated.outcome === "THROWN") {
|
|
return finish(
|
|
violation(
|
|
"VALIDATOR_RUNTIME_FAILURE",
|
|
"VALIDATION",
|
|
preDispatchEffect(isCommand),
|
|
),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
if (validated.outcome === "INVALID") {
|
|
return finish(
|
|
violation(
|
|
"FINAL_REQUEST_INVARIANT_FAILED",
|
|
"REQUEST",
|
|
preDispatchEffect(isCommand),
|
|
),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
|
|
const projected = projectRequest(
|
|
operation,
|
|
validated.value,
|
|
dependencies.baseUrl,
|
|
);
|
|
if (!projected.ok) {
|
|
return finish(
|
|
violation(
|
|
"FINAL_REQUEST_INVARIANT_FAILED",
|
|
"REQUEST",
|
|
preDispatchEffect(isCommand),
|
|
),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
|
|
// §7.7 / §8.4. Credentials are resolved before send. A response 401 is
|
|
// terminal; there is no hidden refresh-and-replay.
|
|
let patchOperation: Promise<CredentialPatchOutcome>;
|
|
try {
|
|
patchOperation = Promise.resolve(
|
|
dependencies.attachCredentials({
|
|
operationId: contract.operationId,
|
|
authProfileId: policy.authProfileId,
|
|
method: contract.method,
|
|
}),
|
|
);
|
|
} catch {
|
|
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
|
}
|
|
const patchResult = await awaitWithAbort(
|
|
patchOperation,
|
|
lifetimeController.signal,
|
|
);
|
|
if (patchResult === ABORTED) {
|
|
if (terminalCancellation === "SCOPE_FENCE") {
|
|
return finish(
|
|
transportFailure(
|
|
"ABORTED_BY_SCOPE",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
return terminalCancellation === "CALLER"
|
|
? finish(cancelled("NOT_STARTED"), "CANCELLED")
|
|
: finish(
|
|
transportFailure(
|
|
"TIMEOUT",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
const patch = patchResult;
|
|
if (patch.kind === "SCOPE_FENCED") {
|
|
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
|
}
|
|
if (patch.kind !== "READY") {
|
|
// A missing credential never downgrades into an anonymous request.
|
|
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
|
}
|
|
if (
|
|
Object.keys(patch.headers).some(
|
|
(name) => name.toLowerCase() === "idempotency-key",
|
|
)
|
|
) {
|
|
return finish(
|
|
violation("UNEXPECTED_IDEMPOTENCY_KEY", "REQUEST", "NOT_STARTED"),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
Accept: "application/json",
|
|
...patch.headers,
|
|
};
|
|
if (contract.requestBody === "JSON") {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
if (intentValidation.intent?.idempotencyKey !== undefined) {
|
|
headers["Idempotency-Key"] = intentValidation.intent.idempotencyKey;
|
|
}
|
|
|
|
const retryCeiling = Math.min(
|
|
policy.retryBudget,
|
|
dependencies.maxRetryAttempts,
|
|
HTTP_EXECUTION_CEILINGS.hardRetryCount,
|
|
);
|
|
|
|
for (let retryIndex = 0; ; retryIndex += 1) {
|
|
if (callerSignal?.aborted) {
|
|
terminalCancellation ??= "CALLER";
|
|
return finish(
|
|
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
|
"CANCELLED",
|
|
);
|
|
}
|
|
if (!context.scope.isCurrent()) {
|
|
terminalCancellation ??= "SCOPE_FENCE";
|
|
return finish(
|
|
scopeFenced(contractViolationEffect(attemptState, isCommand)),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const budget = remaining();
|
|
if (budget <= 0) {
|
|
return finish(
|
|
transportFailure("TIMEOUT", false, attemptState, isCommand),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
const deadlineTimer = setTimeout(() => {
|
|
terminalCancellation ??= "DEADLINE";
|
|
controller.abort();
|
|
}, budget);
|
|
const forwardLifetime = () => {
|
|
controller.abort();
|
|
};
|
|
if (lifetimeController.signal.aborted) forwardLifetime();
|
|
else {
|
|
lifetimeController.signal.addEventListener("abort", forwardLifetime, {
|
|
once: true,
|
|
});
|
|
}
|
|
|
|
const init: RequestInit = {
|
|
method: contract.method,
|
|
headers,
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
credentials: patch.credentials,
|
|
cache: "no-store",
|
|
signal: controller.signal,
|
|
...(projected.request.bodyBytes
|
|
? { body: projected.request.bodyBytes.slice() }
|
|
: {}),
|
|
};
|
|
|
|
const invariantFailure = checkFinalInvariants({
|
|
request: projected.request,
|
|
expectedMethod: contract.method,
|
|
baseUrl: dependencies.baseUrl,
|
|
init,
|
|
headers,
|
|
requestByteLimit: policy.requestByteLimit,
|
|
deadlineRemainingMs: remaining(),
|
|
scopeIsCurrent: context.scope.isCurrent(),
|
|
});
|
|
if (invariantFailure) {
|
|
clearTimeout(deadlineTimer);
|
|
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
|
return finish(
|
|
invariantFailure === "SCOPE_FENCED"
|
|
? scopeFenced(preDispatchEffect(isCommand))
|
|
: violation(
|
|
"FINAL_REQUEST_INVARIANT_FAILED",
|
|
"REQUEST",
|
|
preDispatchEffect(isCommand),
|
|
),
|
|
"NOT_STARTED",
|
|
);
|
|
}
|
|
|
|
let response: Response;
|
|
attemptState = "READY_TO_SEND";
|
|
try {
|
|
attempts += 1;
|
|
const pending = fetcher(projected.request.url, init);
|
|
attemptState = "DISPATCHED";
|
|
response = await pending;
|
|
attemptState = "RESPONSE_HEADERS";
|
|
} catch {
|
|
clearTimeout(deadlineTimer);
|
|
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
|
const owner = terminalCancellation;
|
|
if (owner === "CALLER") {
|
|
return finish(
|
|
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
|
"CANCELLED",
|
|
);
|
|
}
|
|
if (owner === "SCOPE_FENCE") {
|
|
return finish(
|
|
transportFailure(
|
|
"ABORTED_BY_SCOPE",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
const kind = owner === "DEADLINE" ? "TIMEOUT" : "NETWORK_FAILURE";
|
|
if (
|
|
owner === null &&
|
|
canRetryTransport(contract.retrySemantics, attemptState) &&
|
|
retryIndex < retryCeiling &&
|
|
remaining() > 0
|
|
) {
|
|
const delay = jitteredDelay(retryIndex, random);
|
|
if (delay < remaining()) {
|
|
const slept = await awaitWithAbort(
|
|
sleep(delay, lifetimeController.signal),
|
|
lifetimeController.signal,
|
|
);
|
|
if (slept === ABORTED) {
|
|
return terminalCancellation === "CALLER"
|
|
? finish(
|
|
cancelled(
|
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
),
|
|
"CANCELLED",
|
|
)
|
|
: finish(
|
|
transportFailure(
|
|
"TIMEOUT",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
return finish(
|
|
transportFailure(kind, false, attemptState, isCommand),
|
|
kind,
|
|
);
|
|
}
|
|
|
|
try {
|
|
const outcome = await admitResponse(
|
|
operation,
|
|
response,
|
|
context,
|
|
attemptState,
|
|
readResponseBytes,
|
|
);
|
|
attemptState = "SETTLED";
|
|
if (
|
|
outcome.retryHint &&
|
|
retryIndex < retryCeiling &&
|
|
isRetryableSemantics(contract.retrySemantics)
|
|
) {
|
|
const delay = retryDelayFor(outcome.retryAfterMs, retryIndex, random);
|
|
if (delay !== null && delay < remaining()) {
|
|
clearTimeout(deadlineTimer);
|
|
lifetimeController.signal.removeEventListener(
|
|
"abort",
|
|
forwardLifetime,
|
|
);
|
|
const slept = await awaitWithAbort(
|
|
sleep(delay, lifetimeController.signal),
|
|
lifetimeController.signal,
|
|
);
|
|
if (slept === ABORTED) {
|
|
return terminalCancellation === "CALLER"
|
|
? finish(
|
|
cancelled(
|
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
),
|
|
"CANCELLED",
|
|
)
|
|
: finish(
|
|
transportFailure(
|
|
"TIMEOUT",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
"TIMEOUT",
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
return finish(outcome.result, outcome.certainty);
|
|
} finally {
|
|
clearTimeout(deadlineTimer);
|
|
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
|
}
|
|
}
|
|
} catch {
|
|
return finish(
|
|
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
|
|
"RUNTIME_FAILURE",
|
|
);
|
|
} finally {
|
|
disposeLifetime();
|
|
}
|
|
}
|
|
|
|
return Object.freeze({ execute });
|
|
}
|
|
|
|
type AdmissionOutcome<Value, Problem> = Readonly<{
|
|
result: HttpExecutionOutcome<Value, Problem>;
|
|
certainty: string;
|
|
retryHint: boolean;
|
|
retryAfterMs: number | null;
|
|
}>;
|
|
|
|
/**
|
|
* §7.9. Admission order is fixed: redirect, status allowlist, media type,
|
|
* declared length, bounded stream, body policy, decode, validate, scope fence.
|
|
* `response.ok` is never used on its own.
|
|
*/
|
|
async function admitResponse<Input, WireOutput, Problem>(
|
|
operation: InstalledHttpContract<Input, WireOutput, Problem>,
|
|
response: Response,
|
|
context: HttpExecutionContext,
|
|
attemptState: PhysicalAttemptState,
|
|
readResponseBytes: typeof readBoundedBytes,
|
|
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
|
const contract = operation.contract;
|
|
const policy = operation.frontend;
|
|
const isCommand = contract.commandEffect !== null;
|
|
const status = response.status;
|
|
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
|
|
if (response.redirected || response.type === "opaqueredirect") {
|
|
await response.body?.cancel().catch(() => {});
|
|
return settled(
|
|
violation(
|
|
"UNEXPECTED_STATUS",
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
|
|
const metadata = safeMetadata(response, retryAfterMs);
|
|
|
|
if (!contract.acceptedStatuses.includes(status)) {
|
|
if (status === 401) {
|
|
await response.body?.cancel().catch(() => {});
|
|
return settled(
|
|
unauthenticated(
|
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
isCommand,
|
|
),
|
|
"UNAUTHENTICATED",
|
|
);
|
|
}
|
|
if (status === 403) {
|
|
await response.body?.cancel().catch(() => {});
|
|
return settled(
|
|
forbidden(certaintyForAbandonedAttempt(attemptState, isCommand), isCommand),
|
|
"FORBIDDEN",
|
|
);
|
|
}
|
|
if (status === 429) {
|
|
await response.body?.cancel().catch(() => {});
|
|
return Object.freeze({
|
|
result: Object.freeze({
|
|
kind: "RATE_LIMITED" as const,
|
|
...(retryAfterMs === null ? {} : { retryAfterMs }),
|
|
effect: normalizeOptionalEffect(
|
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
isCommand,
|
|
),
|
|
}),
|
|
certainty: "RATE_LIMITED",
|
|
retryHint: true,
|
|
retryAfterMs,
|
|
});
|
|
}
|
|
return admitProblem(
|
|
operation,
|
|
response,
|
|
status,
|
|
metadata,
|
|
attemptState,
|
|
readResponseBytes,
|
|
);
|
|
}
|
|
|
|
// Success status: body policy first.
|
|
if (contract.responseBody === "NONE") {
|
|
const probe = await probeForbiddenBody(response);
|
|
if (!probe.ok) {
|
|
return settled(
|
|
transportFailure(
|
|
"RESPONSE_STREAM_FAILURE",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
probe.code,
|
|
);
|
|
}
|
|
if (probe.present) {
|
|
return settled(
|
|
violation("UNEXPECTED_BODY", "RESPONSE", postDispatchEffect(isCommand)),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
if (!context.scope.isCurrent()) {
|
|
return settled(
|
|
scopeFenced(postDispatchEffect(isCommand)),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
return settled(
|
|
Object.freeze({
|
|
kind: "SUCCESS" as const,
|
|
value: undefined as WireOutput,
|
|
metadata,
|
|
effect: (isCommand
|
|
? "APPLIED_CONFIRMED"
|
|
: "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED",
|
|
}),
|
|
isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE",
|
|
);
|
|
}
|
|
|
|
const emptyAllowed = contract.emptyBodyStatuses.includes(status);
|
|
const mediaOk = isJsonMediaType(response.headers.get("content-type"));
|
|
const bytes = await readResponseBytes(response, policy.responseByteLimit);
|
|
if (!bytes.ok) {
|
|
return settled(
|
|
bytes.code === "RESPONSE_TOO_LARGE"
|
|
? violation(
|
|
"RESPONSE_TOO_LARGE",
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
)
|
|
: transportFailure(
|
|
"RESPONSE_STREAM_FAILURE",
|
|
false,
|
|
attemptState,
|
|
isCommand,
|
|
),
|
|
bytes.code,
|
|
);
|
|
}
|
|
|
|
const empty = isEffectivelyEmpty(bytes.bytes);
|
|
if (empty) {
|
|
if (contract.responseBody === "REQUIRED_JSON" || !emptyAllowed) {
|
|
return settled(
|
|
violation(
|
|
"UNEXPECTED_EMPTY_BODY",
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
if (!context.scope.isCurrent()) {
|
|
return settled(
|
|
scopeFenced(postDispatchEffect(isCommand)),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
return settled(
|
|
Object.freeze({
|
|
kind: "SUCCESS" as const,
|
|
value: undefined as WireOutput,
|
|
metadata,
|
|
effect: (isCommand
|
|
? "APPLIED_CONFIRMED"
|
|
: "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED",
|
|
}),
|
|
isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE",
|
|
);
|
|
}
|
|
|
|
if (!mediaOk) {
|
|
return settled(
|
|
violation(
|
|
"CONTENT_TYPE_MISMATCH",
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
|
|
const decoded = decodeJsonBytes(bytes.bytes);
|
|
if (!decoded.ok) {
|
|
return settled(
|
|
violation(decoded.code, "RESPONSE", postDispatchEffect(isCommand)),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
|
|
const validated = invokeValidator(contract.outputValidator, decoded.value);
|
|
if (validated.outcome === "THROWN") {
|
|
return settled(
|
|
violation(
|
|
"VALIDATOR_RUNTIME_FAILURE",
|
|
"VALIDATION",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
if (validated.outcome === "INVALID") {
|
|
return settled(
|
|
violation(
|
|
"SUCCESS_SCHEMA_INVALID",
|
|
"VALIDATION",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
"CONTRACT_VIOLATION",
|
|
);
|
|
}
|
|
|
|
// §10.8. A successful response for a stale scope is discarded, not committed.
|
|
if (!context.scope.isCurrent()) {
|
|
return settled(
|
|
scopeFenced(postDispatchEffect(isCommand)),
|
|
"SCOPE_FENCED",
|
|
);
|
|
}
|
|
|
|
return settled(
|
|
Object.freeze({
|
|
kind: "SUCCESS" as const,
|
|
value: validated.value,
|
|
metadata,
|
|
effect: (isCommand
|
|
? "APPLIED_CONFIRMED"
|
|
: "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED",
|
|
}),
|
|
isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE",
|
|
);
|
|
}
|
|
|
|
const ABORTED = Symbol("http-operation-aborted");
|
|
|
|
async function awaitWithAbort<Value>(
|
|
operation: Promise<Value>,
|
|
signal: AbortSignal,
|
|
): Promise<Value | typeof ABORTED> {
|
|
if (signal.aborted) return ABORTED;
|
|
let onAbort: (() => void) | undefined;
|
|
const aborted = new Promise<typeof ABORTED>((resolve) => {
|
|
onAbort = () => resolve(ABORTED);
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
try {
|
|
return await Promise.race([operation, aborted]);
|
|
} finally {
|
|
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
}
|
|
}
|
|
|
|
async function admitProblem<Input, WireOutput, Problem>(
|
|
operation: InstalledHttpContract<Input, WireOutput, Problem>,
|
|
response: Response,
|
|
status: number,
|
|
metadata: SafeResponseMetadata,
|
|
attemptState: PhysicalAttemptState,
|
|
readResponseBytes: typeof readBoundedBytes,
|
|
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
|
const contract = operation.contract;
|
|
const isCommand = contract.commandEffect !== null;
|
|
const retryable = RETRYABLE_STATUSES.has(status);
|
|
|
|
const bytes = await readResponseBytes(
|
|
response,
|
|
HTTP_EXECUTION_CEILINGS.problemResponseBytes,
|
|
);
|
|
if (!bytes.ok || isEffectivelyEmpty(bytes.bytes)) {
|
|
// An unclassifiable failure stays uncertain for a command.
|
|
return Object.freeze({
|
|
result: violation<WireOutput, Problem>(
|
|
"PROBLEM_SCHEMA_INVALID",
|
|
"VALIDATION",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
retryHint: retryable,
|
|
retryAfterMs: metadata.retryAfterMs ?? null,
|
|
});
|
|
}
|
|
if (!isJsonMediaType(response.headers.get("content-type"))) {
|
|
return Object.freeze({
|
|
result: violation<WireOutput, Problem>(
|
|
"CONTENT_TYPE_MISMATCH",
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
retryHint: retryable,
|
|
retryAfterMs: metadata.retryAfterMs ?? null,
|
|
});
|
|
}
|
|
|
|
const decoded = decodeJsonBytes(bytes.bytes);
|
|
if (!decoded.ok) {
|
|
return Object.freeze({
|
|
result: violation<WireOutput, Problem>(
|
|
decoded.code,
|
|
"RESPONSE",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
retryHint: retryable,
|
|
retryAfterMs: metadata.retryAfterMs ?? null,
|
|
});
|
|
}
|
|
|
|
const validated = invokeValidator(contract.problemValidator, decoded.value);
|
|
if (validated.outcome !== "VALID") {
|
|
return Object.freeze({
|
|
result: violation<WireOutput, Problem>(
|
|
validated.outcome === "THROWN"
|
|
? "VALIDATOR_RUNTIME_FAILURE"
|
|
: "PROBLEM_SCHEMA_INVALID",
|
|
"VALIDATION",
|
|
postDispatchEffect(isCommand),
|
|
),
|
|
certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
retryHint: retryable,
|
|
retryAfterMs: metadata.retryAfterMs ?? null,
|
|
});
|
|
}
|
|
|
|
const classification = classifyProblemEffect({
|
|
status,
|
|
problem: validated.value,
|
|
descriptor: contract.commandEffect,
|
|
});
|
|
const effect = isCommand
|
|
? classification.effect
|
|
: certaintyForAbandonedAttempt(attemptState, false);
|
|
|
|
return Object.freeze({
|
|
result: Object.freeze({
|
|
kind: "PROBLEM" as const,
|
|
problem: validated.value,
|
|
metadata,
|
|
effect: (effect === "NOT_STARTED"
|
|
? "NOT_APPLIED"
|
|
: effect) as "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED",
|
|
}),
|
|
certainty: effect,
|
|
retryHint: retryable && effect !== "MAYBE_APPLIED",
|
|
retryAfterMs: metadata.retryAfterMs ?? null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED`
|
|
* must not automatically retry once an attempt was dispatched and its response
|
|
* was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero.
|
|
*/
|
|
function canRetryTransport(
|
|
semantics: InstalledHttpContract<
|
|
unknown,
|
|
unknown,
|
|
unknown
|
|
>["contract"]["retrySemantics"],
|
|
attemptState: PhysicalAttemptState,
|
|
): boolean {
|
|
if (semantics === "NEVER") return false;
|
|
if (semantics === "KEYED") {
|
|
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isRetryableSemantics(
|
|
semantics: InstalledHttpContract<
|
|
unknown,
|
|
unknown,
|
|
unknown
|
|
>["contract"]["retrySemantics"],
|
|
): boolean {
|
|
return semantics === "SAFE" || semantics === "IDEMPOTENT";
|
|
}
|
|
|
|
/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */
|
|
function jitteredDelay(retryIndex: number, random: () => number): number {
|
|
const ceiling = Math.min(
|
|
RETRY_MAX_LOCAL_DELAY_MS,
|
|
RETRY_BASE_DELAY_MS * 2 ** retryIndex,
|
|
);
|
|
return Math.floor(random() * ceiling);
|
|
}
|
|
|
|
function retryDelayFor(
|
|
retryAfterMs: number | null,
|
|
retryIndex: number,
|
|
random: () => number,
|
|
): number | null {
|
|
const local = jitteredDelay(retryIndex, random);
|
|
if (retryAfterMs === null) return local;
|
|
if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null;
|
|
return Math.max(local, retryAfterMs);
|
|
}
|
|
|
|
/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */
|
|
function safeMetadata(
|
|
response: Response,
|
|
retryAfterMs: number | null,
|
|
): SafeResponseMetadata {
|
|
const correlation = response.headers.get("x-correlation-id");
|
|
return Object.freeze({
|
|
status: response.status,
|
|
...(correlation && /^[A-Za-z0-9._:-]{1,128}$/.test(correlation)
|
|
? { correlationReference: correlation }
|
|
: {}),
|
|
...(retryAfterMs === null ? {} : { retryAfterMs }),
|
|
});
|
|
}
|
|
|
|
function settled<Value, Problem>(
|
|
result: HttpExecutionOutcome<Value, Problem>,
|
|
certainty: string,
|
|
): AdmissionOutcome<Value, Problem> {
|
|
return Object.freeze({
|
|
result,
|
|
certainty,
|
|
retryHint: false,
|
|
retryAfterMs: null,
|
|
});
|
|
}
|
|
|
|
function violation<Value, Problem>(
|
|
kind: HttpContractViolationKind,
|
|
operation: HttpContractViolation["operation"],
|
|
effect: HttpEffectCertainty,
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
return Object.freeze({
|
|
kind: "CONTRACT_VIOLATION" as const,
|
|
violation: Object.freeze({ kind, operation }),
|
|
effect,
|
|
});
|
|
}
|
|
|
|
function scopeFenced<Value, Problem>(
|
|
effect: HttpEffectCertainty,
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
return violation("SCOPE_FENCED", "RESPONSE", effect);
|
|
}
|
|
|
|
function preDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
|
return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE";
|
|
}
|
|
|
|
function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
|
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
|
|
}
|
|
|
|
function contractViolationEffect(
|
|
attemptState: PhysicalAttemptState,
|
|
isCommand: boolean,
|
|
): HttpEffectCertainty {
|
|
if (!isCommand) return "NOT_APPLICABLE";
|
|
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"
|
|
? "NOT_STARTED"
|
|
: "MAYBE_APPLIED";
|
|
}
|
|
|
|
function unauthenticated<Value, Problem>(
|
|
effect: string,
|
|
isCommand: boolean,
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
return Object.freeze({
|
|
kind: "UNAUTHENTICATED" as const,
|
|
effect: normalizeOptionalEffect(effect, isCommand),
|
|
});
|
|
}
|
|
|
|
function forbidden<Value, Problem>(
|
|
effect: string,
|
|
isCommand: boolean,
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
return Object.freeze({
|
|
kind: "FORBIDDEN" as const,
|
|
effect: normalizeOptionalEffect(effect, isCommand),
|
|
});
|
|
}
|
|
|
|
function cancelled<Value, Problem>(
|
|
effect: "NOT_STARTED" | "NOT_APPLIED" | "MAYBE_APPLIED" | "APPLIED_CONFIRMED",
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
return Object.freeze({
|
|
kind: "CANCELLED" as const,
|
|
effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
});
|
|
}
|
|
|
|
function transportFailure<Value, Problem>(
|
|
kind: HttpTransportFailure["kind"],
|
|
retryable: boolean,
|
|
attemptState: PhysicalAttemptState,
|
|
isCommand: boolean,
|
|
): HttpExecutionOutcome<Value, Problem> {
|
|
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
|
|
return Object.freeze({
|
|
kind: "TRANSPORT_FAILURE" as const,
|
|
failure: Object.freeze({ kind, retryable }),
|
|
effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED",
|
|
});
|
|
}
|
|
|
|
function normalizeOptionalEffect(
|
|
effect: string,
|
|
isCommand: boolean,
|
|
): "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED" {
|
|
if (!isCommand) return "NOT_APPLICABLE";
|
|
return effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_APPLIED";
|
|
}
|