Skip to content

Fallback ladder rungs 2 and 3: the next CDN location, then the failing rendition excluded - #194

Merged
ramesh130 merged 2 commits into
mainfrom
issue-180-host-and-variant
Sep 16, 2026
Merged

ramesh130 merged 2 commits into
mainfrom
issue-180-host-and-variant

Conversation

@ramesh130

@ramesh130 ramesh130 commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #180

Rungs 2 and 3 of ADR-0011 rule 7's ladder, plus the harness addressing they need.

The two rungs

Both are LadderRungs reached through FallbackLadder.climb, never by answering
getFallbackSelectionFor directly — Media3 asks for a fallback before it asks for a retry delay,
and answering the first question on its own would skip rung 1 while it still had budget.
RetryingLoadErrorsTest.noFallbackIsOfferedWhileRungOneStillHasBudget still pins that.

FailedLoad gains fallbackOptions, null when Media3 asked the question that carries none
(getRetryDelayMsFor). Null is "not asked", not "nothing available", and it costs nothing because
every source with somewhere to go asks the other question first.

Rung 2 — NextHost, a FALLBACK_TYPE_LOCATION selection. DASH BaseURL failover is in the
specification (// spec: ISO/IEC 23009-1 §5.6.4, ordered by DVB-DASH's dvb:priority,
// spec: ETSI TS 103 285 §10.8.2.1), and Media3's BaseUrlExclusionList performs it; what is
decided here is only whether to move. Media3 gives an HLS chunk source no location dimension at
all
(it reports one location always), so an HLS failure escalates through rung 2 to rung 3 —
the order rather than a gap. ADR-0011 rule 13's header-refresh slot is still where an HLS host
substitution would go if one is ever wanted; nothing needed it here, and that slot is #179's.

Rung 3 — ExcludeVariant, a FALLBACK_TYPE_TRACK selection.

The exclusion mechanism, and "does not reappear"

Media3's own excludeTrack, via a FallbackSelection, and it is time-bounded — Media3 offers
no untimed form from inside a load. So "a blacklisted variant does not reappear on the next
evaluation" is met by the exclusion duration, not by construction: TRACK_EXCLUSION_MS is one
minute, which is tens of evaluations (a selection is evaluated about once per chunk). A rendition
still broken when it lapses fails once more and is excluded again — self-correcting — and one that
has recovered is back in the ladder without the session ending to find out.

That is deliberately the opposite of superplayer-abr's CEILING_EXCLUSION_MS (1 ms): there the
refusal in canSelectFormat keeps a rung out for as long as the ceiling stands, and the exclusion
only has to unseat the rung that is playing. Nothing refuses a rendition that merely failed to load,
so here the duration is the whole of the mechanism. Both durations match Media3's own figures for
the same decisions; this change is about which failures move, and moving the durations too would
be two changes argued as one.

Rule 8's ceiling constraint, honoured by construction

"Excluding a variant hands Media3 a narrower ladder, and the rung it continues at is still chosen
under the selection ceiling and pace PlaybackPolicy decided." That holds without arithmetic in
this rung, in both engines a decision can be in force on:

  • with superplayer-abr, the ceiling is a refusal in NetworkAwareTrackSelection.canSelectFormat,
    re-checked every evaluation;
  • without it, the ceiling is TrackSelectionParameters.maxVideoBitrate, a constraint
    DefaultTrackSelector applies before a selection is built (EngineBinding.applyTo).

An exclusion only ever removes a rung from what is available, and neither mechanism consults
exclusions, so no exclusion can widen what the policy allows. When the exclusion leaves nothing
under the ceiling, the selection has nothing to continue on and the failure escalates out of rung 3
— which is rule 8's "it moves to rung 4" arriving by the route the rule names, rather than by this
rung second-guessing a ceiling whose rungs it cannot see from inside a load.

A ceiling caps the climb; it does not route it

FallbackRung.TYPED_ERROR as a rungCeiling is the one ceiling that is not a permission to climb —
rung 6 is where the ladder has stopped. climb now says so, so a Fatal.Unsupported "goes to
rung 6 at once" (rule 7) instead of being offered rungs 1–3 on the way past. The rest of the routing
is each rung's: rung 2 refuses every Device class (no host has anything to do with a decoder, which
is how Device.DecoderTransient reaches rung 5 without trying one), and rung 3 refuses
Device.DecoderTransient and Content.ManifestInvalid while accepting Device.DecoderInit — each
asymmetry is the class's own documentation, quoted at the refusal. FallbackLadderTest is that
routing written down.

Harness work

FaultScript gains host as a fourth addressing coordinate — optional and trailing on every
builder method, so every existing addressing form is unchanged and every script written before
it addresses every host as it did. It is the one part of the address that names something from the
URL, and docs/testing.md's Forcing the faults section now says why that is the rule applied
rather than relaxed.

Two stream shapes come with it, because a fault needs something to address, and both are off by
default with the single-host forms byte-identical:

  • SyntheticDashStream.resources(mirrorHost = …) / TestContent.dash(mirrorHost = …) — the same
    media at two BaseURLs with the MPD on the origin. dvb:priority is written out rather than left
    to the profile default because two BaseURLs with PRIORITY_UNSET are one location to Media3's
    getPriorityCountAfterExclusion, i.e. nothing to fail over to.
  • SyntheticHlsStream.resources(secondVariantHost = …) / TestContent.hls(secondVariantHost = …) —
    the higher rendition's playlist and segments on a host of their own. The stream's two renditions
    carry identical bytes and differ only in what they declare, so a host is the only thing about one
    of them a script could name. (This is why servedFrom alone could not do the job: it moves the
    whole stream to a second host rather than giving one rendition an address.)

API surfaces for superplayer-testkit and superplayer-testmedia regenerated and committed.

Tests

FallbackPlaybackTest (new, Robolectric + PlaybackHarness, real DASH and real HLS):

  • a location that fails every attempt is left for the one the manifest also names, playback goes on,
    and the origin was asked exactly 1 + maxRetries times first (rung 1 before rung 2);
  • the location fallback resumes where playback had reached — position sampled across the whole span,
    never backwards (rule 9);
  • a location fallback does not change the cache key: the failover session reads from both hosts
    into one ContentKeyedCache, and a later healthy-origin replay re-fetches zero segments;
  • a rendition that keeps failing is excluded and playback goes on at the other bitrate, with the
    failing rendition asked exactly 1 + maxRetries times and never again;
  • the rendition exclusion resumes where playback had reached;
  • a player without resilience is left to Media3's own fallback table and ends the session.

Every playback fault is a 502, chosen deliberately: Media3's own table is 403/404/410/416/500/503
(DefaultLoadErrorHandlingPolicy.isEligibleForFallback), so a fallback forced with one of those
would have happened on a stock player too and would prove nothing. The stock-player test is the
control that makes that argument checkable.

FallbackLadderTest (new) states the classifier-driven routing, including the acceptance criterion
"a class that must not trigger it leaving the ladder intact": Content.ManifestInvalid is offered
another host but not a rendition of itself. It is stated at the ladder rather than through a
player because LoadErrorInfo carries an IOException, so the decoder classes the ladder must route
past rungs 2 and 3 do not reach a player's load-error path at all — they arrive from the player-error
path #182/#183 are being built on.

RetryingLoadErrorsTest gains the same order through the questions Media3 actually asks: rung 1
while the budget lasts, rung 2 once it is spent, rung 3 only once no location is left, and the
Content.ManifestInvalid split surviving the trip.

Self-review

Standards. No ADR or docs/ rule breached that I can find. ADR-0001 rule 2: both rungs are
internal, the new testkit/testmedia surface is Strings and Ints, no Media3 type escapes.
ADR-0011 rules 7, 8, 9, 13 and 14 each argued at the code that discharges them. Clean-room: // spec:
citations on the DASH BaseURL and DVB-DASH priority behaviour and on RFC 9110 §15.6.3 for the 502;
// ref: on the two Media3 constants and on isEligibleForFallback. docs/api-surface.md: both
surfaces regenerated in the same change. docs/testing.md: updated for the new coordinate, since the
document's "never by a URL" rule needed the exception argued rather than quietly taken. No new
third-party dependency, so no THIRD_PARTY.md row; superplayer-cache becomes a test-only project
dependency of superplayer-resilience, which is phase 5 on phase 4 and so allowed by
docs/modules.md — the assertion cannot live in superplayer-cache, which may not depend on a
phase 5 module.

Smells considered: Data Clumps — (kind, index, firstAttempts, host) is now a four-field clump
repeated across nine builder methods, and extracting an address value type is the obvious cleanup;
not taken, because it would change every existing call site and this ticket requires the existing
addressing forms to be unchanged. Flagging it rather than fixing it. Duplicated Code —
HOST/baseUriOn now exist on both synthetic streams with the same host value; they are independent
fixtures with different path roots and I judged a shared constant to be the worse coupling.
Speculative Generality — NextHost/ExcludeVariant's Device refusals are unreachable from
today's load-error path; kept because they discharge promises written in FailureClass's own KDoc
and because FallbackLadder.climb is the one place that routing can live for #182/#183, and tested
at the ladder rather than through a fabricated LoadErrorInfo. LadderRung.ESCALATING removed —
it was #178's placeholder and has no callers left.

Spec (#180's acceptance criteria): all eight met. One note on the first — "a fault that fails
every attempt at one host" is written in two forms here: the DASH test narrows it to one resource at
that host so the failover happens mid-playback and there is a position to preserve, and the HLS
rung-3 test uses the unindexed "everything this host serves" form. The fault never relents in either.

What was skipped, and why

Emulator and demo runs, per the batch instruction about host memory. The Robolectric harness tests
are the e2e path here; superplayer-cache's and superplayer-abr's suites were run alongside
(assemble check green in full, twice — before and after merging main).

Merge with #179

main moved under me while this was in flight (74de69d, the token refresh). I merged
origin/main — the only conflict was CLAUDE.md's resilience paragraph, and both sides are
kept
: #179's Resilience.standard(headers = …) and TokenRefreshLayer prose intact, with its
closing "rungs 2 and 3 stay empty" sentence replaced by this change's. Resilience.kt itself merged
clean — this change never touched it, since the rungs are registered in FallbackLadder.standard.
./gradlew assemble check re-run green after the merge, and superplayer-resilience's suite forced
with --rerun-tasks: 8 classes, 57 tests, 0 failures.

CI

Dispatched manually (ci.yml is workflow_dispatch: only, so nothing runs on the PR itself).

Green: https://github.com/ramesh130/superplayer/actions/runs/35054765246 — conclusion success.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS

ramesh130 and others added 2 commits September 16, 2026 14:28
`FaultScript` addressed a fault by resource kind, index and attempt, every one
of which is the same on every host — so "host A fails and host B serves" was
inexpressible, and the rungs above a retry are precisely the ones that move a
session somewhere else. `host` is now the fourth coordinate, optional and
trailing, so every script written before it addresses every host exactly as it
did. It is the one part of the address that names something from the URL, and
`docs/testing.md` says why that is the rule applied rather than relaxed.

Two stream shapes come with it, because a fault needs something to address.
`SyntheticDashStream.resources(mirrorHost = …)` declares the same media at two
`BaseURL`s (ISO/IEC 23009-1 §5.6.4) ordered by DVB-DASH's `dvb:priority`, which
is written out rather than left to the profile's default because two locations
with no priority at all are one location to Media3.
`SyntheticHlsStream.resources(secondVariantHost = …)` puts the higher
rendition's playlist and segments on a host of their own, which is the only
thing about one of two renditions carrying identical bytes that a script could
name. Both are off by default, and both single-host forms emit byte-identical
bytes to what they always did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
Rungs 2 and 3 of ADR-0011 rule 7's ladder, reached the only way rule 7 allows:
as `LadderRung`s inside `FallbackLadder`, so both of `RetryingLoadErrors`'
answers still come out of one climb. Media3 asks for a fallback *before* it asks
for a retry delay, and answering the first question directly would have skipped
rung 1 while it still had budget — silently.

Rung 2 is `NextHost`, a `FALLBACK_TYPE_LOCATION` selection: DASH `BaseURL`
failover is in the specification (ISO/IEC 23009-1 §5.6.4) and Media3's own
`BaseUrlExclusionList` performs it, so what is decided here is only *whether* to
move. Media3 gives an HLS chunk source no location dimension at all, so an HLS
failure escalates through this rung to rung 3 — the order rather than a gap, and
ADR-0011 rule 13's reserved slot is still where a host substitution would go.
The content is the same content, so the cache key is unchanged: `ContentKeys`
keys on the content id and the URI path and never the host, and a failover is
now asserted against a `ContentKeyedCache` so it cannot quietly become a miss.

Rung 3 is `ExcludeVariant`, a `FALLBACK_TYPE_TRACK` selection — Media3's own
track exclusion, asked for on the classification rather than on Media3's fixed
table of statuses. Rule 8 holds by construction rather than by arithmetic: an
exclusion only narrows what is available, while the ceiling is a
`canSelectFormat` refusal under `superplayer-abr` and
`TrackSelectionParameters.maxVideoBitrate` without it, so no exclusion can widen
what the policy allows, and one that leaves nothing under the ceiling escalates
to rung 4 on its own. The exclusion is time-bounded because Media3 offers no
other kind, which is the opposite of `CEILING_EXCLUSION_MS`'s one millisecond
and is argued where the minute is chosen.

A ceiling caps the climb and does not route it: which rungs below a class's
`rungCeiling` are worth offering is the ladder's, so a `Device.DecoderTransient`
reaches rung 5 without trying a host, an unusable manifest is offered another
host but no rendition of itself, and a `Fatal.Unsupported` — whose ceiling is
the one that is not a permission to climb — is offered nothing at all.

`FallbackPlaybackTest` forces both rungs through the harness with a 502, a
status Media3's own table does not list, so a fallback that happens is the
ladder's; the same file counts what a player without resilience does with that
script, which is end the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
@ramesh130
ramesh130 force-pushed the issue-180-host-and-variant branch from a2182f0 to 358e475 Compare September 16, 2026 04:28
@ramesh130
ramesh130 merged commit 2c0b75d into main Sep 16, 2026
1 check passed
@ramesh130
ramesh130 deleted the issue-180-host-and-variant branch September 18, 2026 06:47
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.

Fallback ladder rungs 2 and 3: the next CDN host, then blacklist the failing variant

1 participant