Skip to content

fix(fallback): start the hedge when the primary fails, not when its timer expires - #33

Merged
bojieli merged 2 commits into
mainfrom
fix/hedge-fires-on-primary-failure
Sep 4, 2026
Merged

fix(fallback): start the hedge when the primary fails, not when its timer expires#33
bojieli merged 2 commits into
mainfrom
fix/hedge-fires-on-primary-failure

Conversation

@bojieli

@bojieli bojieli commented Sep 4, 2026

Copy link
Copy Markdown
Owner

What

The Swift FallbackTranscriber always slept the full hedgeAfter before 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:

A primary that fails rather than stalls hands over immediately — there is nothing left to wait for.

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:

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. 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 AsyncStream signal that the primary opens when it throws. Two things it deliberately does not do:

  • Cancellation does not count as failure. The hedge having already won must not be read as the primary dying.
  • Success never yields. A normal dictation has to leave the hedge asleep, because the winner's cancelAll() is the entire reason a fast primary never costs a second request.

On the test that was already there

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, 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.

Before fix After fix
testAFailingPrimaryFallsBackWithoutWaitingOutTheDelay fails at 8.11s passes in 0.026s

The 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

Suite Result
Swift 602 tests, 0 failures
Windows C# 629 tests, 0 failures; dotnet format whitespace --verify-no-changes clean
Android Kotlin 282 JVM unit tests, 0 failures

Deliberately not in this PR

The log line still reads primary stalled; starting the fallback even 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

bojieli and others added 2 commits September 4, 2026 14:05
…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>
@bojieli
bojieli merged commit 37306ca into main Sep 4, 2026
13 checks passed
@bojieli
bojieli deleted the fix/hedge-fires-on-primary-failure branch September 4, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant