Add ProviderError::Indeterminate for a submit that timed out after sending - #119
Merged
Conversation
|
…nding (#36's double-send gap) A submit whose response/read timed out after the request was already written to the wire was indistinguishable from "provider down" and got resubmitted on retry -- a real duplicate-SMS risk with no idempotency key anywhere, already flagged as a known, accepted gap in app/sms-worker/tests/kill9_reclaim_live.rs. - ProviderError::Indeterminate (crates/sms-provider): the request reached the provider, or may have; retrying is unsafe. Distinct from Unavailable in exactly that respect. RoutingConsequence gains HoldIndeterminate so the routing() match stays compiler-checked. - OrangeCmProvider::classify_transport_error (sms-provider-orange-cm): reqwest::Error::is_connect() is checked first (never wrote a byte, safe to retry -> Unavailable); is_timeout() || is_body() once past that point is a post-connect failure -> Indeterminate. A 2xx whose body is unparseable or missing resourceURL is also Indeterminate, not Unavailable -- Orange already accepted the submission by then. OrangeCmConfig::connect_timeout/request_timeout are now configurable (defaulted in production()) so tests can force a deterministic timeout without waiting on the 10s/30s production values. - routed -> uncertain (docs/architecture.md §2.10 table + §7.4 diagram, schema/migrations/postgres/0002_bootstrap/up.sql regenerated via ci/gen-bootstrap-sql.py, not hand-edited). Verified with ci/assert-state-machine-parity.py. - dispatch.rs drives the new edge and, on Indeterminate only, stamps Message.providerMessageRefAlt = message.id (== the callbackData Orange was sent, known before the network call regardless of outcome) so a later DLR can still correlate via sms_api::dlr::ingest_one's providerMessageRef-OR-Alt match. Found live: without this the claim genuinely did not hold -- an uncertain message from a timed-out submit had neither ref column set and a matching DLR silently failed to correlate. Live tests (crates/sms-worker/tests/dispatch_live_postgres.rs) prove the message lands in uncertain, that no second submit request ever reaches the mock (wiremock's own received-request count, not just DB state), that a connect-level failure still backs off to queued as before, and that a DLR arriving afterward now genuinely resolves the message. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI reproduced a real, ~30%-flaky failure in this suite (3/10 runs), not a fluke: two different tests' wiremock .expect(1) mocks intermittently saw 2 requests instead of 1. Root cause: dispatch::tick's claim loop (claim.rs::candidates()) selects any eligible accepted/queued/routed message system-wide with an expired-or-absent lease -- correctly matching production, since the loop has no way to know which test seeded which row. This binary's database (sms-test-support's per-binary design, #118) is shared by every test in the file and never reset between runs. TEST_MUTEX already serializes execution, but does nothing about residual state: a message a previous test left non-terminal is exactly as claimable as the row the current test is about to seed, and claim_batch's budget (up to tps_ceiling rows per tick) means a single tick() can claim both in the same batch -- so a test's own tick() submits a foreign leftover message to its own wiremock server on top of its own legitimate one. That mismatch is what tripped the affected mocks' .expect(1), and it is order-dependent (hence intermittent), not a data race. Fixed by draining the claimable backlog to a terminal state (cancelled, reachable directly from all three claimable states per §2.10) before every test seeds its own message, through CrateStack delegates only (R1) -- an isolated_db() helper that every test now calls instead of db() directly, so isolation can't be forgotten at a new call site. Under the same TEST_MUTEX every test already holds, so by induction the candidate set at the start of any test contains only rows that test itself goes on to seed. Verified: 15 consecutive clean runs of `cargo test -p sms-worker --test dispatch_live_postgres -- --ignored` (0 failures across 120 individual test executions, well past the 10-run bar). Two full `just test-live` runs both green across all 14 suites/66 tests; one interleaved run hit the pre-existing, unrelated sms-auth::rbac_layer2_live_postgres pg_type-cache flake (#102) in a file this change never touches, confirmed by passing cleanly in isolation immediately after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stephane-segning
force-pushed
the
claude/indeterminate-submit
branch
from
August 7, 2026 01:29
41a2c70 to
3815e09
Compare
This was referenced Aug 7, 2026
stephane-segning
added a commit
that referenced
this pull request
Aug 7, 2026
…NTS.md (#123) Four PRs landed in one session (#118 harness, #120 gate stabilisation, #119 ProviderError::Indeterminate, #121 fake Orange + chaos suite) and the context file only picked up what the last two agents wrote inline. The biggest omission was the most consequential change: `sms-test-support` and the fact that CI now runs the 14 live-Postgres suites at all — previously `cargo test --workspace` ran without `--ignored`, so they were skipped in CI and had only ever run by hand. Records, in each case why it exists rather than just that it does: - The harness's five load-bearing constraints, each learned by something breaking: no container handle in a `static` (Drop never runs for statics, which leaked 56 containers), label-scoped cleanup only (an image-scoped sweep would destroy a developer's own database), one database per test binary (a shared one let a real dispatch subprocess steal other suites' messages), migration-content fingerprinting (an existence check served a stale schema silently), and the global container name that makes concurrent test runs corrupt each other. - That `ca653a1`'s mutex was never applied to two suites, both of which flaked in CI within hours of #118 running them for the first time. - `Indeterminate`'s connect-vs-read predicate, the `routed -> uncertain` edge it needed, and the fact that it trades a possibly lost message for never sending a duplicate — a product decision, not a free win. - A CLI-skew warning placed at both sites that tell you to run `cratestack migrate diff`, since the installed 0.7.4 CLI emits DDL the pinned 0.6.7 library never does. The warning previously existed only in an unrelated paragraph 150 lines away from the commands themselves. - The missing-`hasRole('system')` gap as an explicitly *unguarded* failure mode, now found seven times. The three entries above it each turned a silent failure loud; this one has no equivalent, which is why it recurs. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
4 tasks
stephane-segning
added a commit
that referenced
this pull request
Aug 8, 2026
CI's live job failed on reclaims_a_routed_row_abandoned_by_a_crashed_worker while both new tests (concurrent_if_match_updates_never_both_win, a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued) passed. Locally this suite passed 9/9 repeatedly, including a full just test-live sweep — the failure was CI-only, a slower runner widening a window that already existed. Confirmed mechanism: candidates() orders by `priority DESC, createdAt ASC LIMIT budget`, and every fixture in this file seeds priority: 1000 (the max), so ties break purely by age. This binary's database is never reset between tests or between cargo test invocations. concurrent_if_match_updates_never_both_win alone leaves 15 accepted rows behind every run (only the winning racer's stateReason changes; state stays accepted, since that field is deliberately not what the race updates), and a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued leaves one more. That older-createdAt residue can fill budget=10 before reclaims_a_routed_row_abandoned_by_a_crashed_worker's own freshly-abandoned (and therefore newest-createdAt) row is reached — on a slow enough runner, exactly the failure CI hit. Same defect shape dispatch_live_postgres.rs already found and fixed (#119). Reused its proven fix rather than inventing a second mechanism: clear_claimable_backlog drains every accepted/queued/routed row to cancelled (reachable from all three per §2.10) before a test seeds its own fixture, through CrateStack delegates only (R1); isolated_db() wraps db() with it so no new test can forget the call. The two tests that build their own Cratestack pool directly (bigger connection limits) call clear_claimable_backlog explicitly instead of going through isolated_db(). ca653a1's TEST_MUTEX is untouched and still required — it serializes execution within this binary; it was never meant to, and doesn't, clean up residue between tests. Verified: cargo test -p sms-worker --test claim_live_postgres -- --ignored 10 consecutive runs, 9/9 green every time. Two full just test-live sweeps green. just check, just lint, just parity, and ./ci/assert-no-raw-sqlx.sh all green. No production code touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stephane-segning
added a commit
that referenced
this pull request
Aug 8, 2026
#124) * Keep the two claim-loop tests written during the #119 CI investigation Both were written as throwaway diagnostics while investigating a `two_concurrent_claimers_never_both_win_the_same_row` CI failure that turned out to be a bad assertion rather than a real double-claim. They are worth keeping as standing regression tests, so this renames them, drops the "diagnostic" framing, and states the invariant each one guards. - `a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued` pins `take_lease`'s `accepted` branch leaving no real lease. If a future change gives that hop a real lease, every `accepted` row would stall behind a lease it never needed, and this fails instead of it going unnoticed. It also stands as the answer to a misreading that has already cost real time once: one message id appearing in two claimers' results is not evidence of a double-claim, which is exactly why the neighbouring test now asserts on reaching `routed`. - `concurrent_if_match_updates_never_both_win` races N concurrent `if_match` updates against one row at one starting version, bypassing `claim_batch` entirely, and asserts exactly one wins. This is the guarantee the whole claim loop rests on, one layer below it. `cratestack` is pinned exactly and moves fast; if a future bump ever weakened `if_match`, every CAS claim in this system would begin double-claiming with nothing else noticing. Verified sound on the current pin (=0.6.7). Both take the per-binary `TEST_MUTEX` and the standard live-Postgres `#[ignore]` reason, so they run under `just test-live` and the CI live job like every other suite. Their original `#[ignore = "diagnostic — not for CI"]` would not have excluded them anyway: the live job runs `cargo test --workspace -- --ignored`, which runs every ignored test regardless of the reason string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Isolate claim_live_postgres.rs's tests against residual claimable rows CI's live job failed on reclaims_a_routed_row_abandoned_by_a_crashed_worker while both new tests (concurrent_if_match_updates_never_both_win, a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued) passed. Locally this suite passed 9/9 repeatedly, including a full just test-live sweep — the failure was CI-only, a slower runner widening a window that already existed. Confirmed mechanism: candidates() orders by `priority DESC, createdAt ASC LIMIT budget`, and every fixture in this file seeds priority: 1000 (the max), so ties break purely by age. This binary's database is never reset between tests or between cargo test invocations. concurrent_if_match_updates_never_both_win alone leaves 15 accepted rows behind every run (only the winning racer's stateReason changes; state stays accepted, since that field is deliberately not what the race updates), and a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued leaves one more. That older-createdAt residue can fill budget=10 before reclaims_a_routed_row_abandoned_by_a_crashed_worker's own freshly-abandoned (and therefore newest-createdAt) row is reached — on a slow enough runner, exactly the failure CI hit. Same defect shape dispatch_live_postgres.rs already found and fixed (#119). Reused its proven fix rather than inventing a second mechanism: clear_claimable_backlog drains every accepted/queued/routed row to cancelled (reachable from all three per §2.10) before a test seeds its own fixture, through CrateStack delegates only (R1); isolated_db() wraps db() with it so no new test can forget the call. The two tests that build their own Cratestack pool directly (bigger connection limits) call clear_claimable_backlog explicitly instead of going through isolated_db(). ca653a1's TEST_MUTEX is untouched and still required — it serializes execution within this binary; it was never meant to, and doesn't, clean up residue between tests. Verified: cargo test -p sms-worker --test claim_live_postgres -- --ignored 10 consecutive runs, 9/9 green every time. Two full just test-live sweeps green. just check, just lint, just parity, and ./ci/assert-no-raw-sqlx.sh all green. No production code touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
stephane-segning
added a commit
that referenced
this pull request
Aug 8, 2026
* Keep the two claim-loop tests written during the #119 CI investigation Both were written as throwaway diagnostics while investigating a `two_concurrent_claimers_never_both_win_the_same_row` CI failure that turned out to be a bad assertion rather than a real double-claim. They are worth keeping as standing regression tests, so this renames them, drops the "diagnostic" framing, and states the invariant each one guards. - `a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued` pins `take_lease`'s `accepted` branch leaving no real lease. If a future change gives that hop a real lease, every `accepted` row would stall behind a lease it never needed, and this fails instead of it going unnoticed. It also stands as the answer to a misreading that has already cost real time once: one message id appearing in two claimers' results is not evidence of a double-claim, which is exactly why the neighbouring test now asserts on reaching `routed`. - `concurrent_if_match_updates_never_both_win` races N concurrent `if_match` updates against one row at one starting version, bypassing `claim_batch` entirely, and asserts exactly one wins. This is the guarantee the whole claim loop rests on, one layer below it. `cratestack` is pinned exactly and moves fast; if a future bump ever weakened `if_match`, every CAS claim in this system would begin double-claiming with nothing else noticing. Verified sound on the current pin (=0.6.7). Both take the per-binary `TEST_MUTEX` and the standard live-Postgres `#[ignore]` reason, so they run under `just test-live` and the CI live job like every other suite. Their original `#[ignore = "diagnostic — not for CI"]` would not have excluded them anyway: the live job runs `cargo test --workspace -- --ignored`, which runs every ignored test regardless of the reason string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Isolate claim_live_postgres.rs's tests against residual claimable rows CI's live job failed on reclaims_a_routed_row_abandoned_by_a_crashed_worker while both new tests (concurrent_if_match_updates_never_both_win, a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued) passed. Locally this suite passed 9/9 repeatedly, including a full just test-live sweep — the failure was CI-only, a slower runner widening a window that already existed. Confirmed mechanism: candidates() orders by `priority DESC, createdAt ASC LIMIT budget`, and every fixture in this file seeds priority: 1000 (the max), so ties break purely by age. This binary's database is never reset between tests or between cargo test invocations. concurrent_if_match_updates_never_both_win alone leaves 15 accepted rows behind every run (only the winning racer's stateReason changes; state stays accepted, since that field is deliberately not what the race updates), and a_second_claim_batch_call_picks_up_the_row_the_routing_hop_just_queued leaves one more. That older-createdAt residue can fill budget=10 before reclaims_a_routed_row_abandoned_by_a_crashed_worker's own freshly-abandoned (and therefore newest-createdAt) row is reached — on a slow enough runner, exactly the failure CI hit. Same defect shape dispatch_live_postgres.rs already found and fixed (#119). Reused its proven fix rather than inventing a second mechanism: clear_claimable_backlog drains every accepted/queued/routed row to cancelled (reachable from all three per §2.10) before a test seeds its own fixture, through CrateStack delegates only (R1); isolated_db() wraps db() with it so no new test can forget the call. The two tests that build their own Cratestack pool directly (bigger connection limits) call clear_claimable_backlog explicitly instead of going through isolated_db(). ca653a1's TEST_MUTEX is untouched and still required — it serializes execution within this binary; it was never meant to, and doesn't, clean up residue between tests. Verified: cargo test -p sms-worker --test claim_live_postgres -- --ignored 10 consecutive runs, 9/9 green every time. Two full just test-live sweeps green. just check, just lint, just parity, and ./ci/assert-no-raw-sqlx.sh all green. No production code touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
crates/sms-provider/src/error.rs— newProviderError::Indeterminate { message }variant andRoutingConsequence::HoldIndeterminate, so therouting()match stays a compiler-checked total function.crates/sms-provider-orange-cm/src/lib.rs— a newclassify_transport_errorthat sorts a.send()failure intoUnavailable(safe to retry) vsIndeterminate(unsafe) by connect-vs-post-connect phase; a 2xx with an unparseable body or a missingresourceURLis nowIndeterminatetoo, notUnavailable.OrangeCmConfiggainsconnect_timeout/request_timeout(defaulted inproduction()) so tests can force a real, deterministic timeout instead of waiting on the 10s/30s production values.crates/sms-worker/src/dispatch.rs—classify()routesIndeterminatetorouted -> uncertainwith no backoff (no next attempt);write_transitiongained aprovider_ref_altparameter so theIndeterminatepath stampsMessage.providerMessageRefAlt.docs/architecture.md— §2.10's transition table and §7.4's mermaid diagram both gainrouted -> uncertain; §6.1's illustrativeProviderErrorcode sample updated to match.schema/migrations/postgres/0002_bootstrap/up.sql— regenerated viaci/gen-bootstrap-sql.py(one-line diff: the new transition row). Not hand-edited.crates/sms-worker/tests/dispatch_live_postgres.rs— three new live tests proving the state transition, the no-double-submit guarantee, the connect-failure non-regression, and DLR resolution.Intent
Source of truth:
AGENTS.md's own documented gap —app/sms-worker/tests/kill9_reclaim_live.rsalready pins as a known, accepted limitation that a crash-and-resubmit in a certain window produces two real outbound SMS, because "providerMessageRefhas no DB-level uniqueness constraint and nothing today gives Orange a dedup key." This PR addresses the sibling case: a submit that times out after the HTTP request was already written to the wire was, before this change, classified identically to "provider is down" (ProviderError::Unavailable), anddispatch's ownclassify()sends everyUnavailableback toqueuedfor another attempt — i.e. a guaranteed resubmit on exactly the request shape most likely to have already succeeded once.Scope
ProviderErrorvariant, the Orange adapter's transport-error classification, therouted -> uncertainstate edge (doc + regenerated bootstrap SQL), driving it fromdispatch, and deterministicwiremock-backed tests.providerMessageRef's missing DB-level uniqueness/dedup key (thekill9_reclaim_live.rs-documented gap), and any change toundelivered -> queuedretry (still undriven, percrates/sms-api/src/dlr.rs's own module doc).Verification
The
reqwestpredicate, and why it's correct:reqwest::Error::is_connect()istrueonly for a failure establishing the connection itself (DNS, TCP handshake, TLS handshake — including a connect-phase timeout), verified by readingreqwest0.12.28's own source (is_connectwalks the error chain for ahyper_util::client::legacy::Errorreporting connect). At that point this adapter has not written a single byte onto a socket Orange controls, so retrying is exactly as safe as it always was. Checked first and returns early.is_timeout()istruefor a timeout at either phase; by construction (theis_connectbranch already returned) reaching this check means the connection was already established and the timeout fired while writing the request body or waiting on Orange's response..json(&body)fully buffers the request before.send()starts writing it, so by the time this fires the write is complete or in progress — Orange's server may already have the full request.is_body()covers the same "past connect" territory from a different failure shape (the connection was reset/closed mid-transfer rather than the client's own timeout firing) — grouped with the timeout case for the same reason.Verified against real
reqwest::Errorvalues, not synthetic ones:a_connect_refusal_is_still_unavailablebinds an ephemeral TCP port, drops the listener (so the address is valid but refuses every connection), and assertserror.is_connect()before checking the classification;a_post_connect_timeout_is_indeterminateuses a realwiremockserver with a response delayed past the client's own timeout, and asserts!error.is_connect()as a test-setup guard before checking the classification — so a broken predicate would fail the test's own setup assertion, not just the finalmatches!.The 2xx-but-malformed-body decision: yes,
Indeterminate, notUnavailable. Orange returning201means the submission was accepted — full stop — regardless of whether we can then parse the confirmation body. Two new unit tests (submit_returns_201_but_an_unparseable_body_is_indeterminate,submit_returns_201_but_a_missing_resource_url_is_indeterminate) cover this directly.The state edge — both doc surfaces changed, bootstrap regenerated, not hand-edited:
That's the entire diff
ci/gen-bootstrap-sql.py schema/migrations/postgres/0002_bootstrap/up.sqlproduced after editing §2.10's SQL block and §7.4's mermaid diagram indocs/architecture.md— confirmed this change touches only0002_bootstrap(hand-written SQL), notschema.cstack, so nocratestack migrate diff/0001_initregeneration was needed.ci/assert-state-machine-parity.pypasses:message: 25 edges, diagram and table agree.The
providerMessageRefAltcorrelation claim — did NOT hold on the first pass, found live, then fixed:The assignment asked me to verify this claim against
crates/sms-api/src/dlr.rsrather than assume it, and specifically to report plainly if it didn't hold. It did not, initially.OrangeCmProvider::submitonly returns aSubmitAck(which carriesprovider_ref/provider_ref_alt) on success; on anIndeterminatefailure there is noSubmitAckat all, and the originalwrite_transitionnever wrote anything toproviderMessageRef/providerMessageRefAlt. My first version of the live DLR test (a_dlr_after_an_indeterminate_submit_still_correlates_and_resolves) failed exactly this way — the message stayeduncertainbecausesms_api::dlr::ingest_one's correlation query (providerId+providerMessageRefORproviderMessageRefAlt) found nothing to match.Fixed by having
dispatch.rsstampproviderMessageRefAlt = message.idon theIndeterminatepath specifically:SubmitRequest::reference(alwaysmessage.id) is sent to Orange asreceiptRequest.callbackDatabefore the network call that might time out, so it's known regardless of whether a response ever comes back — unlikeSubmitAck's fields, which only exist on success. With that fix,a_dlr_after_an_indeterminate_submit_still_correlates_and_resolvespasses: a synthetic DLR echoing that same reference now correlates and drives the message fromuncertaintodelivered.A second, unrelated bug found live while writing these tests, worth flagging explicitly: the shared
sms-test-supportPostgres container (from #118) reuses its template database across runs, and its migration check only looks forpublic.messages's existence — not migration currency. Since this PR's schema change adds a row rather than a table, the container from a prior session did not pick up the newrouted -> uncertaintransition until I ranjust test-live-cleanand let it re-migrate from scratch. Both of my first two live-test failures traced back to this (a stale DB, not a code bug) — worth calling out since it'll bite the next schema-only bootstrap change the same way.Live suite, run twice, from a freshly re-migrated container:
dlr_ingestion_live_postgreserrors_live_postgrespolicy_golden_list_live_postgressend_message_live_postgressms-auth live_postgresoidc_flow_liveprovision_app_client_live_postgresrbac_layer2_live_postgresm1_acceptance_gate_live_postgresclaim_live_postgresdispatch_live_postgresjobs_live_postgressms-worker live_postgreskill9_reclaim_live66 tests, 14/14 suites green, on both the run immediately after
test-live-cleanand a second, fully independent rerun. One transient failure on the very first (pre-clean-container) attempt —sms-auth'srbac_layer2_live_postgres::a_route_this_middleware_does_not_gate_is_unaffectedhitduplicate key value violates unique constraint "pg_type_typname_nsp_index", the pre-existing, previously-documentedpg_type-cache race (#102) — passed cleanly on its own in isolation immediately after; not a regression from this change (I touched nosms-authcode).Containers: 12 before I started (11 of the developer's own unrelated
tenant-provisioner-*/vsms-test-harness-postgrescontainers were already present from prior work) →just test-live-cleanremoved the one stale harness container (11) → both fulljust test-liveruns left the count at 12 (11 unrelated + 1 harness), no growth across either run.Screenshots/Evidence
N/A — backend state-machine/error-taxonomy change, no UI surface. Evidence is the test output above and the diff itself.
Risk Assessment
Low for the code paths touched. The new variant only activates on a specific, narrow transport shape (post-connect timeout/body error, or a malformed 2xx) that did not previously have any handling more correct than "treat as
Unavailable" — so this is strictly a refinement of existing error handling, not a new failure mode.routed -> uncertainis an additive state-machine edge; nothing that previously reacheduncertain(via DLR) changes behavior.The tradeoff, named plainly, not hidden: this deliberately trades a possible lost message for never sending a duplicate. If a message reaches
uncertainvia this path and Orange genuinely never sent it (a true, non-transient failure that merely looked like a timeout), no DLR will ever arrive, and the message sits untilexpire_stale's 6-hour grace (crates/sms-worker/src/jobs/expire_stale.rs, already handlesuncertain— confirmed, unchanged by this PR) reaps it toexpiredrather than being retried quickly. That's the accepted cost of closing the double-send gap: a slower failure mode for the rare case, instead of a real duplicate SMS to a real handset for the more consequential one.Also unverified, honestly: whether Orange's real production behavior actually matches the "connect vs. post-connect" model this predicate assumes — this repo has no Orange sandbox credentials (documented pre-existing limitation,
crates/sms-provider-orange-cm/src/lib.rs's own module doc), so everything here is proven againstwiremock, not a real Orange endpoint under real network conditions.AI Usage Declaration
Implemented end-to-end by Claude Code (Claude Sonnet 5) under my direction: reading the existing
ProviderError/dispatch/dlrcode first rather than trusting the assignment's own paraphrase of it (which named a nonexistentRetryThisProviderProviderErrorvariant — that name only exists onRoutingConsequence; the actual five pre-existingProviderErrorvariants arePermanent/Transient/Unavailable/Rejected/Unsupported), designing and implementing the fix, and verifying it against a live Postgres and realreqwesterrors rather than trusting a greencargo checkalone.reqwestpredicate and the reasoning behind it, and independently verified it againstreqwest0.12.28's actual source (error.rs) rather than trusting my own or the tooling's assumption about its semantics.providerMessageRefAltcorrelation claim by writing a live test that initially failed, diagnosed why, and fixed the actual gap rather than adjusting the test to hide it — see "Verification" above.just test-liveagainst real Docker/Postgres, twice, and take responsibility for the pass counts and container counts reported above being copied from actual command output, not estimated.Reviewer Focus
is_timeout() || is_body()is the right boundary, or whetheris_body()deserves its own, more specific branch — I grouped it with the timeout case on the reasoning that both mean "past the connect phase," butis_body()can in principle fire for a request-body streaming error before any bytes reached the server in some hyper internals I did not independently trace line-by-line (this adapter's request body is fully buffered via.json(), which is why I believe this doesn't apply here — but a second pair of eyes on that assumption specifically would be valuable).providerMessageRefAltonly on theIndeterminatepath (not onTransient/Unavailabletoo) is the right scope — I limited it to the one case that actually needs future DLR correlation, but flag it in case a reviewer sees value in doing this unconditionally for future-proofing.sms-test-support's migration check not detecting bootstrap-SQL-only changes) is real and will recur on the next such change — worth deciding whether to file it as a follow-up against Add a testcontainers-backed live-Postgres harness; wire it into CI (#102) #118 rather than leaving it as tribal knowledge in this PR body.