Skip to content

Refresh an expired CDN token inside the transfer that met the refusal - #193

Merged
ramesh130 merged 1 commit into
mainfrom
issue-179-token-refresh
Sep 16, 2026
Merged

ramesh130 merged 1 commit into
mainfrom
issue-179-token-refresh

Conversation

@ramesh130

Copy link
Copy Markdown
Owner

Closes #179

An expiring CDN token is the failure PRD.md §3.3 calls among the commonest in production and
almost 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 handed
    CredentialRefusal(uri, status, sentHeaders) and returning the complete header set the player
    should carry from now on. Uri, Int and Map only: no Media3 type in the signature, which the
    .api diff shows.
  • Resilience.standard(headers = …) — the one argument the module takes. The credential is the
    app'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 no
    provider is unchanged and still fills the slot with the pass-through, so the LoadKind stamp
    Build 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 in EngineConfiguration.headerRefresh
    when 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.segment to maxRetries = 0, so rung 1 cannot ask for anything twice. A session that
heals 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 first
carrying no Authorization and the second carrying the minted token. A refresh performed after a
retry 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 + 1 exactly, where a refresh that re-asked with an unchanged
credential 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 ErrorClassifier names it as before. 404 and the 5xx band are
deliberately 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 when over errorCode) is untouched.

Self-review

Standards. ADR-0001 rule 2 (no @UnstableApi type on the public surface) — the .api diff names
Uri and Map only. ADR-0011 rules 12 and 13 — the refresh is not a PlaybackDecision half, not a
profile 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 — updateApiSurface run, diff committed here. docs/testing.md — every
behavioural claim is driven through the public API under Robolectric with no network;
TokenRefreshLayerTest is a unit test of an internal class for the one claim a player cannot be
asked (a swallowed addTransferListener is invisible from outside), on the precedent of
RetryingLoadErrorsTest and with the reason in its KDoc. Clean-room — every status and header
convention carries its RFC 9110 citation; no new dependency, so no THIRD_PARTY.md row.
Judgement calls kept rather than fixed: Refreshing writes out every DataSource method instead of
delegating with by (a Middle Man by the baseline, and required — Kotlin delegation does not forward
Java default methods, ADR-0003's trap); and retryPolicyOf is ~20 duplicated lines shared with
RetryPlaybackTest, left where it is rather than extracted into a shared fixture, to keep out of the
way 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's
suite 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.kt is touched in one place only: the headerRefresh line in configureEngine and the
constructor and KDoc around it. The ladder map, RetryingLoadErrors and the load-error wiring are
untouched. main did not move while this was in flight (still 231847f), so no merge was needed.

Checks

./gradlew assemble check --max-workers=1 green locally, including superplayer-abr's oracle and
QoE 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

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
@ramesh130
ramesh130 merged commit 74de69d into main Sep 16, 2026
1 check passed
@ramesh130
ramesh130 deleted the issue-179-token-refresh 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.

Refresh an expiring CDN token on 401 or 403, before the retry

1 participant