Skip to content

Phase 6a: retry — documentation and phase record - #74

Merged
Wahbeh-Mohammad merged 16 commits into
mainfrom
22-phase-6a-retry-docs
Sep 19, 2026
Merged

Wahbeh-Mohammad merged 16 commits into
mainfrom
22-phase-6a-retry-docs

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #22. Third and last PR of phase 6a's stack — the documentation and the phase record for the two PRs below it. Targets the tests PR's branch. Its CLAUDE.md, READMEs, architecture.md and roadmap text are written for 6a on top of main; phase 6c's stack rewrites the same sentences for 6c, and whichever merges second re-derives the counts.

What lands

9 files, +1,344 / −43, all Markdown.

  • The checklist, docs/work/mvp/phase6/phase6a/2026-09-09-phase6a-retry-checklist.md, written from the build: 62 own rows — 42 RETRY ✅, RETRY-4 owned by phase 8a's Task 2, RETRY-29 / RETRY-38 / RETRY-43 ⏳ (declined for v1), the fifteen RECOV rows ✅ each naming its 6a task with its RETRY twin as an annotation, RECOV-31 ⏳ beside RETRY-38, CFG-35 ✅ (the inherited throwable half) — plus 20 cross-reference rows; the matrix facts re-run on every interpreter; 38 guards run red with their messages; the audit groups; 33 deviations from the plan; the findings routed; postponed work: none.
  • The phase 6a design's ledger gains an "As built" addendum, P6-51–P6-61: the cursor widening's keyword, RetrySettings#random as the ::Random class, the zero-initial-delay guard, the trampoline (the design's recursive sketch overflows), the async fatal passthrough, the flat RetryPredicateError, the clamp/fallback diagnostics under 5b's events, retries_exhausted's exact trigger, RetrySettings.build's logger:, Policy.cancellation?, the bounded pacing grammars. The design's P6-1–P6-12 stand; 6c's design rows collide with them by number (recorded by the roadmap on 2026-09-10) and phase 10 consolidates.
  • docs/sdk-documentation/retry.md — new as-built page, 76 example values run on 4.0.6 and 3.2.11; architecture.md, the core README, README.md (whose "nothing emits the HTTP-tracer vocabulary yet" is now false) and docs/README.md point at it.
  • CLAUDE.md — "Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c and 6a are built", the opening paragraph gains the retry layer, 140 → 149 lib files beside version.rb, thirteen private_constant test-mirror exceptions, twelve checklists, and four "Constraints that will bite" lines (one calculator and two budget policies; the baked flag is #retryable_by_status? and never #retryable?; every pillar step forks for every drive; the async driver's delay needs a scheduler and a zero delay completes inline).
  • The roadmap — the 2026-09-18 "Phase 6a implemented" status note plus the two review-round paragraphs, naming the four postponed items that landed and the two OBS-29 residuals that stay on phase 10's list, and one phase-10 inbound bullet (the design's pump sketch). Pipeline.standard is explicitly not claimed — 6b's Task 13a.
  • docs/first-release.md untouched: the P6-4 transport-wrapping entry, the RECOV-31/RETRY-38 entry and the OBS-29 behavioural-asymmetry entry were verified present and cited, not re-filed. docs/deviations.md untouched (phase 10 flips the rows).

Verification

  • bundle exec rake on Ruby 4.0.6 at this tip: exit 0, all seventeen gates green (2,285 / 65,255, 99.98%, YARD 0 undocumented).
  • ruby .claude/skills/housekeeping/probe.rb: no drift, all eight checks (--only registers,citations also clean); verify_knowledge_structure.rb OK (2,166 harvested entries, 53 notes).
  • Empty diff under docs/product-spec/, docs/sdk-design-ruby/, docs/knowledge/, docs/deviations.md, docs/first-release.md, every earlier phase's documents, the phase-6 charter, and 6b's and 6c's documents.
  • The final reviewer re-derived every count CLAUDE.md and the status note state against the tree (the probe does not read the spelled-out lib-file count) and ran every retry.md example on both interpreters.

Known follow-ups (not blocking)

  • The phase-10 inbound bullet the roadmap note adds is numbered "the forty-third"; 6c's stack adds one too, and the phase-5 leftover routing will add more — the ordinal is reconciled when the second stack rebases.
  • The design's recursive AsyncRetryStep sketch and its RETRY-30 paragraph stay in the design text (frozen by convention); the correction lives in the As-built addendum (P6-54) and on phase 10's inbound list, and a human applies it.

Add Dexpace::Resilience -- Policy (the two-axis classifier consult, the
backoff calculator, the total pacing-header parser over the private
PacingParsers, the retry-count resolver, the recovery-only budget and
RETRY-12's constants), Resend.eligible? (the re-sendability gate over
phase 1's idempotent set and phase 3b's replayability), and the frozen
RetrySettings both retry stacks build from, the first reader of 5a's
Keys::MAX_RETRY_ATTEMPTS -- plus the flat Dexpace::RetryPredicateError.

Widen two earlier-phase files in place: ProtocolError bakes XCUT-5's
retryable_by_status? from 5a's Retryability at construction (phase 4b's
postponement; deliberately not #retryable?, P6-10), and HTTPDate's day
group becomes (\d{1,2}) so RETRY-15's single-digit day parses through the
one RFC 1123 parser (R1); 5a's rejection loop swaps that element for the
bare-date row CFG-31 still refuses.

One calculator, two budget policies: backoff_delay takes no total-timeout
and budget_remaining is a separate function only the recovery engine
names (R6, P6-5). A zero initial delay answers 0.0 before the power is
taken, because 0.0 * Infinity is NaN and [NaN, 8.0].min raises (P6-53).
Widen Pipeline::Cursor with one read-only member, #bundle -- CTX-14's
per-call correlation bundle -- seeded by a new optional `bundle:` keyword
on Pipeline#call and AsyncPipeline#call (Bundle::NONE when omitted),
validated in Cursor.build, threaded through both private drivers'
#advance and carried across #fork exactly as #options is. No existing
signature moves, and Transport.conforms? still accepts both runtimes
because the keyword is optional beside the SPI's three positionals
(P6-51).

The one consumer is 5b's instrumentation step, on both runtimes:
#open_span now takes the cursor's bundle and prefers its tracer factory
when the bundle is not NONE, else the step's own keyword, and
Tracing.correlate is handed the same bundle, so a populated one pushes
its trace and span ids onto the diagnostic context. That is the
reconciled precedence's first clause, which phase 5 recorded as having
no implementation path; a call that seeds nothing resolves exactly as
phase 5 did. The bundle carries no operation name, so the span is still
named by the method token (P6-52), and it is not the retry step's
HTTP-tracer source (P6-7).

Existing tests only widened where the code required it: the cursor's
public-method pin gains #bundle, and the two step suites' hand-rolled
cursor stand-ins answer #bundle with NONE.
Add Resilience::RetryStep and Resilience::AsyncRetryStep at
Stages::RETRY, over the private RetryStepHelpers mixin that holds the
decision, the delay resolution, the two override hooks and the trail
handling both share. Each drives the downstream chain once per attempt
through a fresh Cursor#fork -- the first drive included -- so hop 1 and
hop n are one object and each attempt re-executes the chain with fresh
per-attempt state (RETRY-44); neither writes cursor state of its own.

The synchronous step waits through 5a's cancellable Clock#sleep and
checks the token at the top of every attempt. The async step is a
trampoline, not a recursion: one Pump per call resumes under a re-arm
flag held by a Thread::Mutex across the flip only, so an inline
settlement re-enters #resume without growing the stack (the design's
recursive sketch overflows at about 1,500 attempts on every supported
Ruby); a positive delay goes through Dexpace::Async.delay, which without
a Fiber.scheduler raises SeamError synchronously and fails the future
with the trail attached (R2's third route), and a zero-length delay
continues inline. A settled fatal answering #retryable? is delivered
unclassified rather than retried (P6-55).

OBS-29's per-attempt group is emitted through the tracer the
`http_tracer_factory:` keyword produces once per operation, called with
the cursor (R3, P6-7); retries_exhausted fires only when a retryable
failure met a spent budget. The interface _HTTPTracer phase 5c declined
to declare arrives in the http_tracer sig with the wiring, `context`
untyped because the recovery stack hands a Request where the stage stack
hands a Cursor. The error-status response is closed before the wait and
before any throwable propagates (RETRY-35); no total-timeout is named on
this stack (RETRY-28, P6-5).
Add Resilience::RecoveryRetry, chapter 9's first stack (RECOV-17 to
RECOV-30 and RECOV-34, which phase 4 postponed), installed as
Recovery::Orchestrator's `transport:` decorator rather than as a
ResponseChain step (P6-3): Transform#apply carries no request to
re-send, and only a transport-decorator position can dispatch its own
re-sends while the request chain's stamping stays once per exchange.
Sitting below the orchestrator's rescue, a raising transport is retried
here as an exception (P6-8), each attempt's response is classified
through phase 4b's own Recovery.buffer_error_body then
ProtocolError.for_or_nil -- the buffering is what frees the connection
before the wait -- and the two terminal shapes are a returned response
when the failure was never retryable and a raised throwable otherwise,
with every prior failure attached as suppressed (P6-9).

This is the only stack with a total-timeout: the budget is a
maximum-attempts cap counting the first send as attempt 1 (max_retries
plus one, an identity, P6-6) and the time remaining through
Policy.budget_remaining, so a delay that would overshoot is suppressed
and the last failure surfaced. The HTTP tracer is produced once per
operation with the Request, there being no cursor on this stack.

The entry file now requires the whole retry layer, the entry test pins
the six Resilience constants and the flat error with the private helpers
unreachable, and the runtime surface manifest gains the forty-eight rows
the phase's public surface adds.
Review round 0 of the phase-6a stack found four behaviours the design
states and the code did not fully honour, and one comment nit.

The pacing parsers converted an unbounded digit run: a 10 MB header
value stalled the retry decision for seconds in String#to_f / #to_i and,
past ~309 digits, emitted Ruby's out-of-range warning that the suite's
NFR-6 raiser turned into an error Policy#parse_form's rescue swallowed.
Both grammars now bound their runs at fifteen digits -- the largest run a
Float carries exactly, and 10**15 seconds is thirty million years, so a
longer run is RETRY-16's out-of-range value and answers nil without a
conversion -- and a 64-byte ceiling in front of every parser keeps the
HTTP-date attempt from reading a hostile value either (the longest
well-formed form is the 29-byte RFC 1123 date).

RetryStep#wait emitted the tracer's attempt_failed outside the RETRY-35
fence, so a tracer that raised left the superseded response open on the
sync driver while the async driver closed it. The emission now runs
inside the same fence as the delay resolution, so the response is
closed before the raise propagates on both drivers.

A caller's should_retry answering true retried a downstream
CancelledError. Policy.cancellation? walks the cause chain for a
CancelledError; Policy.retryable? answers false for one before either
branch, and the stage drivers' decision answers :stop for one before the
re-sendability gate and before the predicate, so RETRY-23's "never" holds
on all three drivers whatever a capability or a predicate says.

A negative configured MAX_RETRY_ATTEMPTS raised at RetrySettings.build,
so RETRY-41's clamp-and-log was unreachable from any driver.
RetrySettings.build takes a logger: keyword, read once at build, and
resolves the configured value through Policy.effective_max_retries, so a
negative configured value is clamped to the default and the clamp logged
as one contained config diagnostic; an explicit negative max_retries: is
still refused by RECOV-34.

Also: step.rb's class comment cited P6-52 for the no-operation-name
statement, which is P6-51's; the RBS mirrors follow each change and the
core manifest gains Policy#cancellation? (1004 -> 1005 rows).
Review round 1 of the phase-6a stack found the sync/async drift round 0
closed for attempt_failed still open on the terminal path: RetryStep's
settle emitted the tracer's retries_exhausted outside any fence, so a
tracer that raised there propagated while the terminal error-status
response it had just been handed stayed open, whereas the async pump's
finish already ran inside its guarded block and closed it.

The trail attachment and the retries_exhausted emission now run inside
the same fence as the decision and the delay resolution, so a throwing
tracer propagates (OBS-30) with the terminal response closed first on
both drivers; a tracer that does not raise leaves the returned response
open exactly as before (RETRY-34), and the exception path is untouched.
The class comment and the method's comment state the terminal path too.
Add the three doubles phase 6a's retry suites fold over, all under
test/support/ and top level like every double since phase 2:
ScriptedTransport, which consumes a per-call script of responses,
Exceptions and callables so a suite can stage 503, 503, 200 or a run of
throwables and assert with assert_same that one request object was
re-sent every time; ScriptedAsyncTransport, its SEAM-16 twin over a
fresh Completer per call, settling inline by default (the shape that
overflowed the plan's recursive pump) or held pending under
settle_later: with settle_next! for the cancel-in-flight cases; and the
RetryFixtures mixin over RecoveryFixtures -- a request per method, a
response over a real ResponseBody so body.closed? is the RETRY-35
assertion, RetryableError and UnretryableError answering XCUT-6's
capability, RetryableFatal for RETRY-25, jitter-free settings over a
FakeClock, and the RecordingCursor that forwards to the real cursor the
driver minted so the fork-for-every-drive rule is asserted against the
real runtime.

Phase 2's FakeTransport is untouched: seven suites and three doubles
require it by name and shape.
Add policy_test.rb (the two-axis classifier consult, the backoff
calculator including the 2,000-attempt run that found the NaN at
attempt 1,025, the pacing-header parse in every form RETRY-15 names, the
retry-count resolver's clamp and its logged diagnostic, and the
recovery-only budget), resend_test.rb (the re-sendability gate over
phase 1's idempotent set and 3b's replayability, with the streaming and
chunked bodies that can never be re-sent) and retry_settings_test.rb
(the frozen settings, its validators, the UNSET sentinel and the first
read of 5a's Keys::MAX_RETRY_ATTEMPTS under a fake config source).

Extend two earlier-phase suites exactly as their postponements said:
protocol_error_test.rb gains the nested RetryableTest over the baked
retryable_by_status? flag, agreeing with Retryability across 400..599
and deliberately not answering #retryable? (P6-10); http_date_test.rb
gains RETRY-15's two single-digit-day tests, one for what the widening
admits and one for what it still refuses.
Extend five phase-4c and phase-5b suites with the tests of the one
widening Task 8 made. cursor_test.rb's BundleTest: Cursor#bundle is
NONE by default, the seeded object at every position and across every
fork, refused when not a Bundle, and no writer of any name appears.
pipeline_test.rb's and async_pipeline_test.rb's BundleSeedingTest: the
`bundle:` keyword seeds the cursor for one call, the empty pipeline
still dispatches with no cursor, both runtimes remain a Transport by the
duck type, and a non-Bundle is refused before any step runs.
step_test.rb's and async_step_test.rb's BundlePrecedenceTest: the
reconciled precedence's first clause -- the seeded bundle's tracer
factory wins over the step's keyword, the keyword over the constant, a
call that seeds nothing resolves exactly as phase 5 did, and the seeded
bundle's ids reach the diagnostic context through Tracing.correlate.
Add retry_step_test.rb and async_retry_step_test.rb, both driven through
a REAL Pipeline or AsyncPipeline over the scripted transports and a
RecordingCursor, so the fork-for-every-drive rule is asserted against
the runtime that mints the cursor. The synchronous suite covers
eligibility (the two axes, the re-sendability gate no predicate can
override), the three terminal paths with the trail attached to the
surfaced instance, the delay ladder in RETRY-39's order with the
override hook's fall-through logged, the predicate's abort as
RetryPredicateError, the budget, cancellation at the top of every
attempt and inside the wait, the HTTP-tracer group in OBS-29's order
with retries_exhausted only after a retryable failure, and the source
guards (no Kernel#sleep, no total-timeout named on this stack).

The asynchronous suite adds the trampoline: 2,000 inline-settling
attempts complete without growing the stack, a positive delay without a
Fiber.scheduler fails the future synchronously with the trail attached
and a zero-length delay continues inline (R2), a positive delay under
5a's ParkingScheduler parks a fiber and resumes, a cancelled future
aborts at the next boundary while an in-flight attempt is allowed to
land, and a settled fatal answering #retryable? is delivered
unclassified (P6-55).
Add recovery_retry_test.rb: the classification of each attempt through
4b's own buffer-and-classify primitives with the buffered response what
every later line sees, the two terminal shapes (a returned response when
the failure was never retryable, a raised throwable otherwise, with the
trail attached to the constructed ProtocolError so RETRY-34's cause
discrimination holds on this stack), the attempts cap counting the first
send as attempt 1, the total-timeout applied as time remaining so no
wait can overshoot and a hint past the budget suppresses the wait, the
cancellation token at every boundary and inside the wait, the
HTTP-tracer group produced once per operation with the Request, and the
engine installed as Recovery::Orchestrator's transport: with the request
chain's stamping applied once per exchange rather than once per attempt.

Add budget_equivalence_test.rb, the sub-phase's convergence point: the
three drivers built from ONE RetrySettings and driven against an
identical failure sequence exhaust after the same number of wire sends,
three under the shared defaults -- max-retries plus one is max-attempts,
an identity and not a coincidence (P6-6).
Review round 0's two blocking findings and the test halves of its five
should-fix findings, on the tests layer.

The three driver suites built their default settings off the live
configuration slot, so a host MAX_RETRY_ATTEMPTS changed their answers
(=0 broke the recovery suite, =-1 all three). Each suite's Fixtures
module now installs a FakeConfigSource env seam in setup and resets the
slot in teardown, the shape retry_settings_test.rb and
budget_equivalence_test.rb already had; the seven suites run identically
with the variable set to 0 and to -1.

error/retry_predicate_error.rb, a public lib file, had no test mirror;
retry_predicate_error_test.rb pins its shape (StandardError, Dexpace::
Error, flat under Dexpace::, the predicate's raise as #cause).

For the code fix on the branch below: policy_test.rb's PacingBoundsTest
proves a 10 MB value in every form answers nil in under 50 ms on the
parser alone, that a 400-digit run emits no Ruby warning (recorded with
WarningCapture, since the suite's raiser and Policy#parse_form's fence
had hidden it), the fifteen-digit boundary and the 64-byte ceiling; the
'9' * 300 clamp case moves to the out-of-range set and the unexplained
retry-after-ms exemption is gone. The at-the-cap jitter test asserts
samples on BOTH sides of the cap, which the jitter-then-clip order
cannot pass (the reviewer's surviving mutation m02). A tracer raising in
attempt_failed is asserted to close the superseded response on both
stage drivers. A should_retry answering true is asserted not to retry a
downstream CancelledError on both stage drivers, a cancellation wrapped
by a retryable error is terminal on all three, and Policy.cancellation?
has its own cases. A negative configured MAX_RETRY_ATTEMPTS is asserted
clamped and logged at RetrySettings.build, contained, with an explicit
negative max_retries: still refused; the Policy method pin gains
cancellation?.
Review round 2 of the phase-6a stack: the two guards behind round 1's
findings, each seen red on 4.0.6 and 3.2.11.

A tracer raising in retries_exhausted now closes the terminal
error-status response on both stage drivers: the sync case is the one
the fenced settle makes true (round 1's tree leaves the body open) and
the async case pins Pump#finish's guarded block, which closed it all
along. The async suite's tracer tests move into a TracerFencesTest of
their own, because TerminalPathsTest and SharedShapeTest were both at
the class-length ceiling with the new case.

RETRY-31's "never a blocking sleep" clause was stated and not
mechanised: a blocking Clock#sleep inserted beside Async.delay in the
pump's wait left the whole async suite green, because every async case
runs on a FakeClock whose #sleep records and returns and nothing read
it. The inline, parked and no-scheduler cases now assert clock.sleeps
empty, and a text scan refuses the token sleep in async_retry_step.rb,
whose one wait is Async.delay's.
Add the phase-6a checklist, one row per ID in scope: sixty own rows
(RETRY-1 to RETRY-45 and the fifteen RECOV IDs phase 4 handed over),
RECOV-31, the inherited CFG-35 and twenty cross-reference rows, with
the what-was-built summary, the interpreter-matrix facts, the
thirty-one guards run red with the one that stays green and why, the
audit groups, the departures from the plan's text and the findings
routed to their owners. The design gains its As-built addendum,
P6-51 to P6-58.

Add docs/sdk-documentation/retry.md, the twelfth as-built page: the
one policy core, the two stacks and where each sits, the delay ladder,
the budget on each stack, the trail, the HTTP-tracer group and what is
still emitted by nothing, every example run on 4.0.6 and 3.2.11 and
identical on both. architecture.md, docs/README.md, the root README and
the core README point at it.

CLAUDE.md's built-phases paragraph gains the retry layer, its counts
move to one hundred and forty-nine lib/dexpace/ files, thirteen
private_constants without a test/ mirror, twelve checklists and twelve
pages, and its constraints-that-bite list gains five lines. The roadmap
gains its forty-third inbound bullet, the design's recursive async pump
that overflows at about 1,500 attempts, and the 2026-09-18 status note.
docs/first-release.md and docs/deviations.md change in no line.
The checklist's RETRY-10, RETRY-16, RETRY-18, RETRY-19, RETRY-23,
RETRY-35, RETRY-41, RECOV-34 and OBS-20 rows say what round 1 changed
and where it is proven; guards 32-37 join the guards-run-red table, each
seen red on 4.0.6 and 3.2.11; "Deviations from the plan" item 13 is made
true (the three driver suites now carry the configuration seam) and
items 25-30 record the round's repairs against the plan's text; the
"What was built" counts move to eight Policy functions and a 1005-row
manifest and name the error's new test mirror.

The design's As-built addendum gains P6-59 (the configured retry count
clamped and logged at RetrySettings.build through its logger: keyword),
P6-60 (Policy.cancellation? ahead of both classification branches and
the caller's predicate) and P6-61 (the pacing parser's fifteen-digit
runs and 64-byte ceiling), a round-1 paragraph for the fenced tracer
emission, and the P6-2 as-built statement the review asked for:
RetrySettings#backoff_arguments and #header_order are public surface the
manifest locks.

docs/sdk-documentation/retry.md describes the cancellation guard, the
bounded parser and the configured clamp with examples run on 4.0.6 and
3.2.11; CLAUDE.md's retry paragraph names .cancellation? and its
constraints list gains the round's three structural facts; the
roadmap's phase-6a status note gains the round-1 record. The probe is
clean.
Round 1 of the stack's review returned one blocking finding, two
should-fix and one nit; this is the documentation half of the repairs.

The checklist's NFR-13 row had claimed every new .rbs opens with the
SPDX header. None does, as no .rbs in the repository does: the header
reaches sig/ with phase 10's Task 5 and its gates:spdx_rbs, so the row
now claims the twenty new .rb files and points the .rbs half at its
owner. Three counts stale since round 1 read true again: the manifest
grew by 49 rows to 1005, and the as-built ledger runs to P6-61.

The RETRY-35, RETRY-33 and OBS-30 rows state the terminal path's fence
beside the retry path's, the RETRY-26 and RETRY-31 rows say how the
async driver's "never a blocking sleep" is now asserted rather than
stated, guards 38 and 39 join the table with their red messages on both
interpreters, and deviations 31-33 itemise the round. The design's
As-built addendum gains a round-2 paragraph (no ledger row: the terminal
fence deviates from nothing the design states), retry.md's stage-step
paragraph and CLAUDE.md's constraints line say the same, and the
roadmap's 6a note gains the round-2 record.
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* area:resilience Retry, recovery, redirects: RETRY-* RECOV-* REDIR-* labels Sep 18, 2026
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Review record for the phase 6a stack (#72 → #73 → #74)

Three independent reviews, each by a fresh agent with no memory of the previous one, each re-running every gate itself on every tip (4.0.6 all seventeen individually at the code tip and the full rake at the tests and docs tips; the matrix set on 3.2.11 and 3.4.10) and applying its own mutations on both interpreters, with a fix round by a fresh agent between each. The stack was cut from main at f1fe848 and built in parallel with phase 6c's.

Round Verdict Blocking Should-fix Nits Mutations (caught) Disposition
0 changes required 2 5 3 39 (38) all 10 fixed
1 changes required 1 2 1 44 (43) all 4 fixed
2 (final) approve 0 0 2 50 (49) 2 nits carried into the PR bodies

Nothing was skipped. The pre-dispatch cross-check of the plan against the tree as built (the async pump that recurses, the missing Step#bundle_for, the existing FakeTransport, the Random type the RBS gate refuses) meant the reviews found defects in the retry semantics rather than in the scaffolding.

Round 0 → fixed in round 1

  • R0-1 blocking — gems/dexpace-core/test/dexpace/resilience/recovery_retry_test.rb:28: Retry suites read the live configuration slot (real ENV) for max_retries; checklist claim of hermeticity is false. Fixed on tests (a5ba280): Each of retry_step_test.rb, async_retry_step_test.rb and recovery_retry_test.rb carries the FakeConfigSource env seam in its Fixtures module's setup/teardown (retry_settings_test.rb's Hermetic shape); the seven 6a suites answer identically with MAX_RETRY_ATTEM…
  • R0-2 blocking — docs/work/mvp/phase6/phase6a/2026-09-09-phase6a-retry-checklist.md:132: Public lib file error/retry_predicate_error.rb has no test/ mirror; checklist, CLAUDE.md and the roadmap note claim every public file has one. Fixed on tests (a5ba280): gems/dexpace-core/test/dexpace/error/retry_predicate_error_test.rb added (StandardError, includes Dexpace::Error, flat under Dexpace:: per P6-56, the predicate's raise as #cause when raised with cause:, no fields, not InvalidArgumentError); the checklist's Wha…
  • R0-3 should-fix — gems/dexpace-core/lib/dexpace/resilience/pacing_parsers.rb:41: Pacing parser converts unbounded digit runs: a hostile header stalls the retry decision for seconds and emits Ruby warnings under -w. Fixed on code (3515b6f): DECIMAL_GRAMMAR \A\d{1,15}(.\d{1,15})?\z, INTEGER_GRAMMAR \A\d{1,15}\z, and a private MAX_VALUE_BYTES = 64 screen (readable?) in front of every parser; the reviewer's exp: Retry-After/retry-after-ms/X-RateLimit-Reset 10 MB now nil in 0.06–0.14 ms on the parse…
  • R0-4 should-fix — gems/dexpace-core/lib/dexpace/resilience/retry_step.rb:141: Sync RetryStep leaks the open retryable response when the tracer's attempt_failed raises; the async driver closes it. Fixed on code (3515b6f): RetryStep#wait resolves the delay AND emits run.tracer.attempt_failed inside one fenced(response) block, so a raising tracer closes the superseded response before propagating, as Pump#retry_after's guarded block already did; class and method comments updated.…
  • R0-5 should-fix — gems/dexpace-core/lib/dexpace/resilience/retry_step_helpers.rb:77: A downstream CancelledError is retried when a caller's should_retry answers true (RETRY-23: cancellation MUST never be treated as retryable). Fixed on code (3515b6f): Policy.cancellation?(error) = Dexpace.each_cause(error).any?(Dexpace::CancelledError); Policy.retryable? returns false for one before either branch (covers RecoveryRetry and the wrapped-carrier case on all three drivers); RetryStepHelpers#decision returns STOP…
  • R0-6 should-fix — gems/dexpace-core/lib/dexpace/resilience/retry_settings.rb:82: A negative configured MAX_RETRY_ATTEMPTS raises at RetrySettings.build instead of RETRY-41's clamp-and-log. Fixed on code (3515b6f): Option 1 of the reviewer's two: RetrySettings.build(…, logger: Instrumentation::Logger::NULL) and resolve_max_retries(max_retries, logger) resolves the configured key's value through Policy.effective_max_retries(override: nil, configured:, logger:), so a negat…
  • R0-7 should-fix — gems/dexpace-core/test/dexpace/resilience/policy_test.rb:235: RETRY-10 at-the-cap test does not discriminate jitter-then-cap from cap-then-jitter (mutation survived). Fixed on tests (a5ba280): The at-the-cap test draws 200 samples at j = 1.0 and asserts samples.max > 8.0 and samples.min < 8.0 beside the [4.0, 12.0] band; the jitter-then-clip mutation (m02) now fails with 'Expected 8.0 to be > 8.0' on 4.0.6 and 3.2.11 (guard 32). Checklist RETRY-10 r…
  • R0-8 nit — gems/dexpace-core/lib/dexpace/instrumentation/step.rb:54: Class comment cites P6-52 for 'the cursor's bundle carries no name'; P6-52 is the Random default. Fixed on code (3515b6f): step.rb's class comment now cites P6-51 and docs/first-release.md's behavioural-asymmetries entry for the no-operation-name statement.
  • R0-9 nit — docs/work/mvp/phase6/phase6a/2026-09-09-phase6a-retry-design.md:1430: RetrySettings#backoff_arguments and #header_order are public NFR-4 surface the object model and ledger do not name. Fixed on docs (19572fc): The As-built addendum's preamble states that P6-2's public-method list grows by RetrySettings#backoff_arguments and #header_order (and, from round 1, Policy.cancellation? and RetrySettings.build's logger:), with each one's reason; checklist item 30 records it.
  • R0-10 nit — gems/dexpace-core/test/dexpace/resilience/policy_test.rb:446: Unexplained skip of the retry-after-ms 400-digit case. Fixed on tests (a5ba280): The unless bad == '9' * 400 exemption is gone: retry-after-ms answers nil for '9' * 400 through the bounded grammar, and the no-warning test proves the reason the exemption was hiding.

Round 1 → fixed in round 2

  • R1-1 should-fix — gems/dexpace-core/lib/dexpace/resilience/retry_step.rb:186: Sync RetryStep leaks the terminal error-status response when the tracer's retries_exhausted raises; the async pump closes it. Fixed on code (a788218): RetryStep#settle wraps attach_trail and the retries_exhausted emission in fenced(response), so a tracer raising in retries_exhausted propagates with the terminal error-status response closed first, as Pump#finish's guarded block already did; a non-raising trac…
  • R1-2 should-fix — gems/dexpace-core/test/dexpace/resilience/async_retry_step_test.rb:152: RETRY-31's no-blocking-sleep clause is not mechanised: a blocking Clock#sleep added before Async.delay in Pump#wait survives the whole async suite. Fixed on tests (8676267): The inline, parked and no-scheduler async cases assert clock.sleeps empty on the FakeClock the settings carry, and a new 'the async driver names no blocking sleep, by text' test refuses \bsleep\b in async_retry_step.rb (comments stripped) and pins one Dexpace:…
  • R1-3 blocking — docs/work/mvp/phase6/phase6a/2026-09-09-phase6a-retry-checklist.md:141: Checklist NFR-13 row claims every new .rbs opens with the SPDX header; none does. Fixed on docs (ba270fc): The NFR-13 row now reads '✅ for .rb; the .rbs half is phase 10's': the twenty new .rb files (nine lib, eleven test) carry both header lines under phase 0's Dexpace/SpdxHeader cop, verified 9/9 on the code branch; the nine new .rbs carry no SPDX line, as none o…
  • R1-4 nit — docs/work/mvp/phase6/phase6a/2026-09-09-phase6a-retry-checklist.md:139: Three counts inside the checklist are stale after round 1. Fixed on docs (ba270fc): NFR-4 row: 49 rows, 956 to 1005 (48 from the object model plus round 1's Policy#cancellation?); deviation item 17: 1005 rows; the Deviations preface and the Postponed-work sentence: P6-51–P6-61. Also corrected in the same 'What was built' sentence, verified ag…

Round 2 (final) — approve; open nits carried into the PR bodies

  • R2-1 nit — gems/dexpace-core/test/dexpace_test.rb:176: Smoke-suite comment says 'five public Resilience constants' beside an assertion listing six. gems/dexpace-core/test/dexpace_test.rb:176 (code branch, commit 420014e) reads 'the five public Resilience constants, the flat error, and the two private helpers'; the assertion three lines below pins %i[AsyncRetryStep Policy RecoveryRetry Resend RetrySettings RetryStep] -- six -- and Dexpace::Resil…
  • R2-2 nit — gems/dexpace-core/test/dexpace/resilience/async_retry_step_test.rb:142: The async pump's close-before-scheduling order in Pump#retry_after is not pinned by any test (mutation m49 survives, equivalently). Mutation m49 -- wait(delay) moved before Dexpace.close_quietly(response, onto: failure) in Pump#retry_after (async_retry_step.rb:224-230) -- leaves async_retry_step_test.rb green on 4.0.6 and 3.2.11 (33 runs, 165 assertions, 0 failures). By analysis it is equivalent with respect to RETRY-35's pr…

What the final reviewer verified by experiment, both interpreters

  • Preconditions and linearity: main pinned, step.rb and clock.rb on main, merge-base ancestry, the sixteen commits' subjects and bodies, the three layer diffs, the empty diffs against every forbidden pa — main = f1fe848 at start and finish; main ⊂ code ⊂ tests ⊂ docs; subjects 49-71 chars, no attribution, no merges; every forbidden-path diff empty; Resend [:eligible?]; Resilience constants
  • PROBE X1/X1b/X1d (R1-1): a tracer raising in retries_exhausted on the sync RetryStep and on the async pump over [503, 503] with max_retries 1; a quiet tracer on the sync step — X1: RuntimeError propagates, 2 sends, terminal body closed? => true (round 1 saw false). X1b: the future fails, 2 sends, closed? => true. X1d: the same 503 object returned, closed? => false, 2 sends.
  • Mechanical scan of the 6a files: SPDX headers on the 20 new .rb and 9 new .rbs, plain requires, waits, Regexp construction and literals, downcase arguments, bare core constants, ** parameters, Dexpace — 20/20 .rb headers correct (9 lib, 11 test); 0/9 .rbs with SPDX (0/151 in the gem); no plain require added (http_date.rb's require "time" pre-dates 6a); waits are Clock#sleep x2 (retry_step.rb:152, recovery_retry.rb:168
  • The calculator by hand: attempts 1..7, 100 and 100,000; jitter 0.2 bands at d=0.8 and at the cap (5,000 draws each); base == cap at j=1.0; attempt 0; zero initial at attempt 1,025 — [0.2, 0.4, 0.8, 1.6, 3.2, 6.4, 8.0]; 8.0 finite at 100 and 100,000; band [0.72, 0.87995]; cap band [7.20008, 8.79921] with 2,424 above the cap and none negative; base == cap draws [6.989, 6.663, 5.409, 8.858, 7.813]; att
  • The pacing parser: delta-seconds, past/future HTTP-dates, single-digit day, wrong weekday, absent weekday, abc/-1/1e3/'', ms forms, epoch reset draws, past reset, 400 days, 15 vs 16 digits, case-insen — 120.0; 0.0 for the 1994 date; 90.0 for +90; wrong weekday 90.0; single-digit day parses (past => 0.0); absent weekday nil; abc/-1/1e3/'' nil; 1.5 / 0.25; reset draws in [10, 12]; past reset 0.0; 400 days 31536000.0; '9'*
  • HTTPDate after Task 2: 'Sun, 6 Nov 1994', 'Sun, 06 Nov', absent weekday, '', ' ', a three-digit day, a zero day, a doubled space — The two November dates parse to 1994-11-06 08:49:37 UTC; every other form InvalidArgumentError.
  • The baked flag on ProtocolError.for for 408/429/500/502/503/504/599/501/505/404, respond_to?(:retryable?), a frozen instance, a 200 — true for the seven retryable codes, false for 501/505/404; respond_to?(:retryable?) false; the frozen instance answers true; 200 raises InvalidArgumentError.
  • RetrySettings: caller's Set mutated after build, #with(jitter: 9.0), the ~292-year bound at 9_223_372_037 / 036, configured 7 / -1 (with a sink) / abc — [500, 503] unchanged and frozen; #with raises on both (3.2.11 included); 037 refused 'exceeds the representable ceiling (RECOV-34)', 036 accepted; configured 7 => 7 (max_attempts 8); -1 => 2 with one :warn; abc => 2.
  • Classification: the capability two causes deep, a CyclicPair chain, a StreamError, cancellation? plain and wrapped, retryable?(wrapped carrier) — true / terminates false / false / true / true / false.
  • RETRY-14 convergence at the defaults and at max_retries 5 across RecoveryRetry, RetryStep and AsyncRetryStep from one settings — [3, 3, 3] and [6, 6, 6] wire sends.
  • RecoveryRetry beneath a real Orchestrator with an IdempotencyKeyStep (PUT, replayable body) and a custom ErrorMappingStep factory over 503, 503, 404; Recovery.buffer_error_body applied twice — ProtocolError 404 raised; 3 sends; one Idempotency-Key value across all sends; the same request object on every send; the factory called exactly once, with the 404. Re-buffer: original closed, a new Response each time (e
  • The stage step against the real driver: forks/calls on the owning cursor, closed-before-sleep order per superseded response, a StateProbe under Stages::RETRY on every attempt, the default factory, the — forks 3, calls 0; closed_at_sleep [[true, false], [true, true]] with sleeps [0.1, 0.2] and the terminal 200 unclosed; StateProbe reads [{}, {}, {}]; the default factory answers Instrumentation::NULL; every event's contex
  • Cursor widening: no bundle across three nested forks, a seeded bundle across three nested forks, bundle: :x, Transport.conforms?, Cursor.build without bundle:; 5b's Step over a seeded bundle with a Re — NONE on all four reads when unseeded; the identical object on all four when seeded; :x refused with InvalidArgumentError; the pipeline still conforms; Cursor.build defaults to NONE; the bundle's factory made the one trac
  • PROBE X4/X4b: AsyncRetryStep with a positive delay and no Fiber.scheduler (wall time to settlement); a zero delay inline — settled, SeamError, 0.644 ms / 0.564 ms, trail ['first'], one send, no thread blocked; zero delay: settled before #call returned, 200 after 2 sends.
  • PROBE X5/X5b: cancel from another thread 0.2 s into a 30 s Clock::SYSTEM wait, on RetryStep and on RecoveryRetry — CancelledError(:other_thread) after 0.2 s on both drivers, one send; the canceller thread joined.
  • PROBE X7: under 5a's ParkingScheduler a positive delay parks and re-arms (503, 503, 200); a token cancelled during the parked delay — settled 200 after 3 sends, block_count 2, kernel_sleep_count 0; cancel during the park: CancelledError(:token) with trail ['first'] after one send.
  • lib/dexpace.rb: three pairs of Phase 6a require lines swapped (first/last, pacing_parsers/async_retry_step, policy/retry_step_helpers), require 'dexpace' in a subprocess, restored byte for byte — No NameError in any order -- every 6a file requires its own dependencies with require_relative; the block's order is documentation (as rounds 0 and 1 found).
  • Hermeticity: the seven 6a suites plus retry_predicate_error_test with MAX_RETRY_ATTEMPTS unset, 0, -1 and abc in the real ENV — 211 runs / 6502 assertions / 0 failures under every value on both.
  • Tree counts at the docs tip versus CLAUDE.md, docs/README.md, the READMEs and architecture.md, derived from the extracted tree — 150 files under lib/dexpace/ (149 beside version.rb), 150 sig mirrors, no orphan sig, exactly the thirteen named private_constants plus version.rb without a test mirror, 12 checklists, 13 pages (12 beside architecture.md
  • Cited owners for the ⏳ / owner-elsewhere rows and the routed findings — docs/first-release.md:211/225 (P6-4), :337-342 (the declined four), :433 (http_tracer_factory: with cursor), :459 (no operation name); phase 8a's plan Task 2 at line 426; 6b's plan Task 13a at line 1974 -- all exist as c
  • Every example on docs/sdk-documentation/retry.md with the page's helpers (76 checks including the raises' messages) — 76/76 match on both.
  • The design's As-built addendum and the checklist after round 2: P6-1-P6-12 plus P6-51-P6-61, the round-2 paragraph, deviations 31-33, guards 38 and 39, the corrected NFR-13 row, the three counts — All present and consistent with the tree; the round-2 paragraph correctly records the terminal fence as no ledger row; the guard messages match my m38 and m20 runs; no register section (probe --only registers,citations e

Tips reviewed at the final round: code a788218, tests 8676267, docs ba270fc on main f1fe848.

@Wahbeh-Mohammad
Wahbeh-Mohammad changed the base branch from 22-phase-6a-retry-tests to main September 19, 2026 06:54
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 905523c into main Sep 19, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 22-phase-6a-retry-docs branch September 19, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* area:resilience Retry, recovery, redirects: RETRY-* RECOV-* REDIR-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 6a: Retry

1 participant