Repository navigation
Refresh an expired CDN token inside the transfer that met the refusal - #193
Merged
Merged
Conversation
A 401 or 403 is the one load failure a retry cannot mend and a new credential can. `Resilience.standard(headers = …)` now takes a `HeaderProvider` — the app's, because only the app can mint a token — and `TokenRefreshLayer` fills the header-refresh slot with it, asking a refused request once more with what the provider returns before anything above the layer is told a load failed. Composed where `TransferChain` reserves the line, directly over the transport, so the refusal and its repair are one transfer to the cache, to measurement and to CMCD, and rung 1's budget is never spent on a credential that was always going to be refused (ADR-0011 rules 12 and 13). What bounds it is progress rather than a counter: a provider that hands back the headers just refused, or throws, declines the refresh, and the refusal escalates as the CDN's own. The two statuses are read here to decide whether a repair is possible at all, which names no failure — `ErrorClassifier` stays the one place a failure acquires a meaning. `TokenRefreshPlaybackTest` forces every claim through the harness over real HLS against a token that expires mid-session and stops refusing once the credential changes, with the retry budget set to nothing wherever a repair is expected: a session that heals with no retry available can only have healed before one, and the headers on the wire say the second ask already carried the new token. `TokenRefreshLayerTest` covers the one claim a player cannot be asked — that the engine's transfer listener still reaches the transport through the layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
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.
Closes #179
An expiring CDN token is the failure
PRD.md§3.3 calls among the commonest in production andalmost never handled in-app. This fills core's header-refresh slot with the thing that mends it.
What landed
HeaderProvider(public,superplayer-resilience) — a fun interface handedCredentialRefusal(uri, status, sentHeaders)and returning the complete header set the playershould carry from now on.
Uri,IntandMaponly: no Media3 type in the signature, which the.apidiff shows.Resilience.standard(headers = …)— the one argument the module takes. The credential is theapp's to mint and no profile, policy or library default could stand in for it; when a refresh
happens stays correctness and is nobody's to vary (ADR-0011 rule 12).
standard()with noprovider is unchanged and still fills the slot with the pass-through, so the
LoadKindstampBuild RetryPolicy: backoff with jitter, per error class, with separate manifest, segment and licence budgets #178 depends on is untouched.
TokenRefreshLayer(internal) replaces the pass-through inEngineConfiguration.headerRefreshwhen there is a provider. One per player — the credential is the session's, so what a refresh
returns is carried by every later request of that player rather than by the one that was refused.
The argued decisions
Refresh before retry, proved on order rather than outcome. The tests that expect a repair set
RetryPolicy.segmenttomaxRetries = 0, so rung 1 cannot ask for anything twice. A session thatheals under that budget can only have healed inside the one transfer that met the refusal — which is
"before the retry" and "one transfer to everything above" at once. The order itself is then read off
the wire:
harness.networkRequests(player)shows exactly two asks at the faulted segment, the firstcarrying no
Authorizationand the second carrying the minted token. A refresh performed after aretry would have had to spend a retry first, and there was none to spend.
The no-progress case is bounded by progress, not by a counter. One refresh per ask, and only
where the refresh changed the credential: a provider that returns
refusal.sentHeaders, or throws,declines — the original refusal is rethrown unchanged, nothing is asked twice, and the failure that
reaches the consumer is the CDN's rather than a token service's. Asserted by count:
attempts == budget.maxRetries + 1exactly, where a refresh that re-asked with an unchangedcredential would have doubled every one of them. Concurrent refusals make one provider call, not
several: a load whose credential is already stale by the time it is refused takes the newer one
without asking again, which on a feed is the ordinary case.
Reading a status here is not a second taxonomy. The layer reads 401 and 403 (RFC 9110 §15.5.2,
§15.5.4) to decide whether a repair is possible, not to name a failure; a refusal it cannot repair
is left exactly as it was and
ErrorClassifiernames it as before. 404 and the 5xx band aredeliberately not refreshable — a new token mends neither — and a test counts that the provider is
never asked for them. ADR-0011 rule 1 (the single
whenovererrorCode) is untouched.Self-review
Standards. ADR-0001 rule 2 (no
@UnstableApitype on the public surface) — the.apidiff namesUriandMaponly. ADR-0011 rules 12 and 13 — the refresh is not aPlaybackDecisionhalf, not aprofile row, and not a behavioural flag; only the credential's source is an argument, and the KDoc
argues why that is not policy. ADR-0002 — this is a chain layer over the transport, not a stack or a
proxy.
docs/api-surface.md—updateApiSurfacerun, diff committed here.docs/testing.md— everybehavioural claim is driven through the public API under Robolectric with no network;
TokenRefreshLayerTestis a unit test of an internal class for the one claim a player cannot beasked (a swallowed
addTransferListeneris invisible from outside), on the precedent ofRetryingLoadErrorsTestand with the reason in its KDoc. Clean-room — every status and headerconvention carries its RFC 9110 citation; no new dependency, so no
THIRD_PARTY.mdrow.Judgement calls kept rather than fixed:
Refreshingwrites out everyDataSourcemethod instead ofdelegating with
by(a Middle Man by the baseline, and required — Kotlin delegation does not forwardJava default methods, ADR-0003's trap); and
retryPolicyOfis ~20 duplicated lines shared withRetryPlaybackTest, left where it is rather than extracted into a shared fixture, to keep out of theway of the concurrent #180 work.
Spec. Every acceptance box in #179 has a test: self-heal with no session-ending error; the
provider asked on 401 and 403 and not on 500 or 404; the repair as one extra ask below the layer and
none above it; bandwidth estimation unharmed (the listener-forwarding test, plus
superplayer-abr'ssuite green); an unchanged or failing provider bounded and escalating; the order asserted on the
headers the wire carried; position monotonic across the repair; no Media3 type in the provider's
signature. A seventh test is the control — the same fault against the same resilience with no
provider is not repaired at all, and no request carries a credential.
Collision with #180
Resilience.ktis touched in one place only: theheaderRefreshline inconfigureEngineand theconstructor and KDoc around it. The ladder map,
RetryingLoadErrorsand the load-error wiring areuntouched.
maindid not move while this was in flight (still231847f), so no merge was needed.Checks
./gradlew assemble check --max-workers=1green locally, includingsuperplayer-abr's oracle andQoE tests. Emulator and demo runs skipped deliberately — host memory is shared with another worker,
and nothing here changes what the demo exercises beyond the chain a Robolectric harness test already
plays real HLS through.
CI: https://github.com/ramesh130/superplayer/actions/runs/35053691549
🤖 Generated with Claude Code
https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS