fix(fallback): start the hedge when the primary fails, not when its timer expires - #33
Merged
Merged
Conversation
…imer expires
The Swift hedge always slept the full `hedgeAfter` before starting the
secondary, even when the primary had already failed and there was nothing left
to wait for. The docstring has promised the opposite since it was written —
"a primary that *fails* rather than stalls hands over immediately" — and the
C# and Kotlin ports both implement it (`FallbackTranscriber.cs:107-110`,
`FallbackTranscriber.kt:82`). Only Swift did not.
Measured on this machine today, with Gemini returning a non-transient 400 from
a geoblocked egress IP:
09:29:39.467 transcribing model=gemini-3.6-flash provider=google
09:29:41.151 error response status=400 ("not available in your current location")
09:29:41.152 giving up attempt=1 transient=no
09:29:47.901 primary stalled; starting the fallback after=8.0s
09:29:49.021 fallback answered first model=grok-stt provider=xai ms=9554
The primary was definitively dead at 1.7s and the fallback did not start until
8.4s. Every dictation paid ~6.7s of dead air; ten did so before this was found.
The hedge now races its sleep against a one-shot signal the primary opens when
it throws. Cancellation is excluded from that signal, so the hedge having
already won does not count as the primary failing, and success never yields —
a normal dictation must leave the hedge asleep so the winner's `cancelAll`
keeps it from ever costing a second request.
`testAFailingPrimaryFallsBackWithoutWaitingOutTheDelay` existed and passed
against the broken code: it used a 20 ms hedge and asserted only which
transcript came back, and "secondary" comes back either way. It now uses a
realistic 8 s hedge and asserts the elapsed time, which is the actual claim.
The fixture is a 400 rather than a 500 so no retry backoff lands inside the
measurement. Against the old implementation it fails at 8.11s; against this
one it passes in 0.026s. Full suite: 602 tests, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C# and Kotlin ports already hedge the moment the primary fails, but their tests asserted only which transcript came back — the same blind spot that let the Swift port sleep out the full delay undetected for as long as it has. A port that regressed to the Swift behaviour would still have passed here. Both now measure the elapsed time and assert it stays under a second, and both use an 8 s hedge so all three suites describe the same scenario with the same numbers rather than 5 s here and 8 s there. Measured with the delay in place: C# and Kotlin both return in ~0.02 s. Windows: 629 tests, 0 failures; `dotnet format whitespace --verify-no-changes` clean. Android: 282 JVM unit tests, 0 failures, the case itself at 0.021 s. Co-Authored-By: Claude Opus 5 (1M context) <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.
What
The Swift
FallbackTranscriberalways slept the fullhedgeAfterbefore starting the secondary, even when the primary had already failed and there was nothing left to wait for.Its own docstring has promised the opposite since it was written:
The C# and Kotlin ports both implement that (
FallbackTranscriber.cs:107-110,FallbackTranscriber.kt:82). Only Swift did not.The measurement
Found while diagnosing why Gemini stopped working from this network. Google began returning a non-transient 400 to our egress IP, and every dictation since took ~9.5s instead of ~3s. From
donottype.log:The primary was definitively dead at 1.7s. The fallback did not start until 8.4s. That is ~6.7s of dead air per dictation, and ten dictations paid it before anyone noticed — the transcripts were fine, just late, which is exactly why it went unseen.
The fix
The hedge now races its sleep against a one-shot
AsyncStreamsignal that the primary opens when it throws. Two things it deliberately does not do:cancelAll()is the entire reason a fast primary never costs a second request.On the test that was already there
testAFailingPrimaryFallsBackWithoutWaitingOutTheDelayexisted and passed against the broken code. It used a 20 ms hedge and asserted only which transcript came back — and "secondary" comes back either way, just six seconds later. The name described the behaviour; the assertion did not check it.It now uses a realistic 8 s hedge and asserts elapsed time. The fixture is a 400 rather than a 500 so no retry backoff lands inside the measurement, which also matches the failure this was written for.
testAFailingPrimaryFallsBackWithoutWaitingOutTheDelayThe second commit gives the C# and Kotlin suites the same elapsed-time assertion. They behave correctly today, but they had the identical blind spot, so a port regressing to the Swift behaviour would still have gone green.
Verification
dotnet format whitespace --verify-no-changescleanDeliberately not in this PR
The log line still reads
primary stalled; starting the fallbackeven when the primary hard-failed rather than stalled. That wording is identical across all three platforms, so correcting it is a three-port string change and belongs in its own PR.🤖 Generated with Claude Code