fix: preserve command effect certainty across retries

Separate per-attempt physical state from the logical execution history. The
executor now keeps one monotonic certainty accumulator joined through
joinMutationEffectCertainty, records MAYBE_APPLIED at dispatch, and reads the
accumulator from every retry-loop fence, final-invariant, cancellation and
timeout return.

A retry-time scope fence landing between the loop-entry check and the
pre-dispatch invariant can no longer downgrade an already dispatched command to
NOT_STARTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 23:01:48 +09:00
co-authored by Claude Opus 5
parent 4e87bacdf3
commit e06e4377ca
6 changed files with 158 additions and 62 deletions
+42 -61
View File
@@ -30,6 +30,8 @@ import {
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
joinMutationEffectCertainty,
type MutationEffectCertainty,
type PhysicalAttemptState,
} from "./http-effect-certainty.ts";
import { parseRetryAfter } from "./retry-policy.ts";
@@ -413,6 +415,25 @@ export function createContractHttpExecutor(
let attemptState: PhysicalAttemptState = "PREPARING";
let attempts = 0;
/**
* §8.7 / D-01. Per-attempt state stays local to the attempt; this monotonic
* accumulator is the logical execution history. A retry that has not been
* dispatched can never lower what an earlier attempt already established.
*/
let logicalCertainty: MutationEffectCertainty = "NOT_STARTED";
const observeCertainty = (observed: MutationEffectCertainty) => {
logicalCertainty = joinMutationEffectCertainty(logicalCertainty, observed);
return logicalCertainty;
};
/** Pre-dispatch failures read the accumulator, never a fresh attempt. */
const logicalPreDispatchEffect = (): HttpEffectCertainty =>
isCommand ? logicalCertainty : "NOT_APPLICABLE";
const abandonedCertainty = (): MutationEffectCertainty =>
observeCertainty(certaintyForAbandonedAttempt(attemptState, isCommand));
const abandonedTransportFailure = (
kind: HttpTransportFailure["kind"],
): HttpExecutionOutcome<WireOutput, Problem> =>
transportFailure(kind, false, abandonedCertainty());
let terminalCancellation: CancellationOwner | null = null;
const lifetimeController = new AbortController();
const forwardCallerToLifetime = () => {
@@ -571,24 +592,14 @@ export function createContractHttpExecutor(
if (patchResult === ABORTED) {
if (terminalCancellation === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
return terminalCancellation === "CALLER"
? finish(cancelled("NOT_STARTED"), "CANCELLED")
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -647,14 +658,14 @@ export function createContractHttpExecutor(
if (callerSignal?.aborted) {
terminalCancellation ??= "CALLER";
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (!context.scope.isCurrent()) {
terminalCancellation ??= "SCOPE_FENCE";
return finish(
scopeFenced(contractViolationEffect(attemptState, isCommand)),
scopeFenced(abandonedCertainty()),
"SCOPE_FENCED",
);
}
@@ -663,7 +674,7 @@ export function createContractHttpExecutor(
const budget = remaining();
if (budget <= 0) {
return finish(
transportFailure("TIMEOUT", false, attemptState, isCommand),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -710,11 +721,11 @@ export function createContractHttpExecutor(
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
return finish(
invariantFailure === "SCOPE_FENCED"
? scopeFenced(preDispatchEffect(isCommand))
? scopeFenced(logicalPreDispatchEffect())
: violation(
"FINAL_REQUEST_INVARIANT_FAILED",
"REQUEST",
preDispatchEffect(isCommand),
logicalPreDispatchEffect(),
),
"NOT_STARTED",
);
@@ -726,6 +737,9 @@ export function createContractHttpExecutor(
attempts += 1;
const pending = fetcher(projected.request.url, init);
attemptState = "DISPATCHED";
// D-01. Dispatch is the point of no return for the logical execution.
// No later retry may claim the command never started.
observeCertainty(certaintyForAbandonedAttempt("DISPATCHED", isCommand));
response = await pending;
attemptState = "RESPONSE_HEADERS";
} catch {
@@ -734,18 +748,13 @@ export function createContractHttpExecutor(
const owner = terminalCancellation;
if (owner === "CALLER") {
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (owner === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
@@ -765,18 +774,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -784,7 +786,7 @@ export function createContractHttpExecutor(
}
}
return finish(
transportFailure(kind, false, attemptState, isCommand),
abandonedTransportFailure(kind),
kind,
);
}
@@ -817,18 +819,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -843,7 +838,7 @@ export function createContractHttpExecutor(
}
} catch {
return finish(
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
abandonedTransportFailure("NETWORK_FAILURE"),
"RUNTIME_FAILURE",
);
} finally {
@@ -945,8 +940,7 @@ async function admitResponse<Input, WireOutput, Problem>(
transportFailure(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
probe.code,
);
@@ -990,8 +984,7 @@ async function admitResponse<Input, WireOutput, Problem>(
: transportFailure(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
bytes.code,
);
@@ -1323,16 +1316,6 @@ 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,
@@ -1365,10 +1348,8 @@ function cancelled<Value, Problem>(
function transportFailure<Value, Problem>(
kind: HttpTransportFailure["kind"],
retryable: boolean,
attemptState: PhysicalAttemptState,
isCommand: boolean,
effect: MutationEffectCertainty,
): HttpExecutionOutcome<Value, Problem> {
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({ kind, retryable }),