Skip to content

Potential bugs or inefficiencies around the HTTP/3 upgrade mechanism #27

Description

@passcod

🤖 The cancellation-strike mechanism added in #23 treats a cancelled HTTP/3 attempt as weak evidence: N consecutive strikes within a rolling ~60s window demote an origin, and any successful HTTP/3 response clears the count. That reasoning holds for sequential cancellations. Review found three patterns it does not cover.

None is an outage — every failure mode is either "TCP instead of HTTP/3 for 300s" or "stays on HTTP/3 when it should have demoted" — so these are hardening items, not blockers.

1. Concurrent aborts reach the threshold instantly (false positive)

The window bounds strike spacing, not concurrency, and the success-reset only helps if a success actually interleaves. A Promise.all of four GETs to one origin under a shared AbortSignal.timeout, or a batch cancelled on user navigation, produces four strikes within microseconds with no opportunity for a reset.

A perfectly healthy origin is then demoted for upgradeFailedTtl (300s). A caller doing this every minute effectively loses HTTP/3 permanently: 300s on TCP, failed expires, the origin is re-advertised, the next burst re-demotes it.

This means the design's acceptance criterion "an origin whose h3 works normally is never demoted by interleaved caller aborts" does not hold in general — it holds for the sequential case the design reasoned about.

Two cheap hardenings, either of which closes most of it:

  • Debounce: ignore a strike arriving within ~250ms of the previous one. A simultaneous burst is one event, not a run.
  • Minimum in-flight duration: record Instant::now() in H3AttemptGuard::new and only strike if the attempt was in flight beyond some floor. An attempt aborted after 20ms says nothing about the UDP path; one aborted after 5s of silence does.

The second is the more principled of the two: it directly encodes "we waited long enough to have learned something".

2. Retry backoff longer than the window never demotes (false negative)

Strikes decay between attempts, so a retry loop whose backoff exceeds ~60s never accumulates a run and the default config never demotes. The originally reported incident lasted 6.5+ minutes across "multiple retry cycles"; if that consumer's backoff exceeds the window, the default settings would not have helped them.

upgradeCancelStrikes: 1 gives immediate demotion and is now documented as the answer, but the window itself is deliberately not a knob. Worth reconsidering whether it should be, or whether the strike count should decay more slowly than it accumulates.

3. Redirects misattribute strikes

reqwest follows redirects inside next.run, but the middleware holds the initial request URL. A cancellation while following a cross-origin redirect records a strike against the first origin, whose HTTP/3 attempt actually succeeded. Pre-existing behaviour for confirm_h3/record_h3_failure; new for strikes. Low impact, noted for completeness.

Suggested first step

Add the minimum-in-flight-duration floor. It is a couple of lines in H3AttemptGuard, it addresses pattern 1 directly, and it makes the "a strike means we waited and learned nothing" claim true rather than merely intended.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions