Skip to content

Name a failure once: the taxonomy, and the one place a class is assigned - #191

Merged
ramesh130 merged 1 commit into
mainfrom
issue-177-error-classifier
Sep 16, 2026
Merged

ramesh130 merged 1 commit into
mainfrom
issue-177-error-classifier

Conversation

@ramesh130

Copy link
Copy Markdown
Owner

Closes #177.

superplayer-resilience's first code: FailureClass, the sealed taxonomy PRD.md §3.3 names, and ErrorClassifier, the single place a failure acquires a meaning (ADR-0011 rules 1–4). Nothing acts on a classification yet — the retry policy (#178), the ladder (#181) and telemetry's classification field (#183) are the issues that read it.

CI (not attached to PRs in this repo — ci.yml is workflow_dispatch: only): https://github.com/ramesh130/superplayer/actions/runs/35049408550

The decisions, argued

Why the rung ceiling is shaped the way it is. Rule 1 asks each class for "which rung it may reach", so the class carries a FallbackRung — a type, in the ladder's own declaration order, because 3 at a call site says nothing about which rung 3 is — and it is a ceiling, not an itinerary. Which rungs below it are worth attempting is the ladder's to decide (#181), which is how rule 7's Device.DecoderTransient "goes to rung 5 without trying a host" while still having rung 5 as its ceiling. The one rung a class settles by itself is rung 1, and that is exactly what retryable is: a failure that is not retryable is not retried, however much budget the decision in force allows.

The ceilings then say something rather than repeating each other:

  • Fatal.Unsupported → TYPED_ERROR: no remedy is attempted at all. Rule 7's "rung 6 at once".
  • Device.DecoderTransient → RECREATE_DECODER: the one class whose remedy is the top rung.
  • Drm.Provisioning, Drm.LicenceAcquisition → RETRY_SAME_URL: a licence exchange between the device and the licence service is not something another host, another variant or another source has any bearing on.
  • Everything else → NEXT_SOURCE: rungs 2–4 are all plausible remedies for a transfer, a description or a device that could not play this rung, and rung 5 is not — a decoder is not implicated in bytes that never reached one.

How totality was kept without an unknown class. classify takes a Throwable and returns a class for every input, including one that is no PlaybackException at all (the load-error path #178 will hold a bare IOException). Three layers of evidence, strongest first, each allowed to decline: what core already concluded, then the load that failed, then the engine's band. The band is a single when whose every range carries its own fall-through, so a code a later Media3 invents lands with its neighbours, and whose final else covers the miscellaneous band, Media3's negative session codes, an app's custom codes and no band at all. That default is Transient.Network and never Fatal.Unsupported: rule 2 reserves that class for a code that says unsupported, and an unrecognised code is far likelier to be a transfer that can be retried than content that can never play — calling it fatal would end sessions the ladder could have rescued. The test asserts this over the whole code space (−200..8000, plus CUSTOM_ERROR_CODE_BASE and both ends of Int), a looping cause chain and a 200-deep one, rather than over a corpus that happens to be at hand.

Core detects, this maps (rule 4). StaleLivePlaylistException is read by the likelyCause core filled in — INTERMEDIARY_CACHE is a Transient.CdnEdge, because an edge holding a frozen copy is an edge defect and another edge may hold a live one; ORIGIN is a Content.SegmentGap, because segments that were promised are not being produced and asking again finds the same hole. LiveWindowTooShortException is a Content.ManifestInvalid. No playlist age and no manifest attribute is re-read, and core's cache-bypassing reload is not a rung.

What separates Transient.CdnEdge from a plain transfer failure is the LoadKind stamp #176 put on every request of a player with resilience attached: 401/403/404 on a media load is the expired token or edge miss PRD.md §3.3 names; the same status on a manifest, or on an unstamped request, says nothing and falls through to the band. 416 on a media load is a Content.SegmentGap — the object is shorter than the description promised.

Two known disagreements with the error-code band, both foreseen by ADR-0011 rule 3 and both #183's to act on when the field is wired up: a frozen origin is band NETWORK and class SOURCE, and the renderer band is band RENDERER and class DECODER. The renderer band maps onto the Device leaves deliberately — initialising an audio track or a frame processor is a decoder init in every way the ladder cares about — which leaves FailureCategory.RENDERER and UNKNOWN with no row in the table, said out loud in FailureClass's KDoc rather than left to be discovered.

Drm.* is declared and no DRM is plumbed (rule 6). Its three leaves are not placeholders: Media3's DRM band reaches players today and rule 2 leaves nothing unclassified, so each leaf is a code the engine can already raise. Phase 6 adds a leaf in a change that says why.

Review, two axes

Standards. ADR-0011 rules 1–4 followed as written; the taxonomy names no Media3 type and api/superplayer-resilience.api — the module's first entries — confirms it (verifyNoUnstableMedia3InPublicApi passes). One when over errorCode in the repository, which is this one. Every non-obvious constant carries a // ref: or // spec: citation (RFC 9110 for the statuses, Media3's documented band ranges, MediaCodec.CodecException's two flags). No new dependency artifact — media3-datasource, media3-test-utils-robolectric and robolectric are catalog entries already recorded in THIRD_PARTY.md — so no row was needed. docs/testing.md: the test drives the module's public API, asserts past no facade, and touches no device and no network. Smell pass: the Repeated Switches rule is the one this change is most exposed to and rule 1 is the answer to it; Speculative Generality was weighed for the Drm leaves and for FallbackRung's six values (both required — one by rule 2's totality, one by rule 7's fixed order) and no other accessor was added on spec. CLAUDE.md's orientation gained the paragraph and the module gained a line in the test-sources sentence.

Spec. Against #177's body and rules 1–4: the taxonomy is public and Media3-free, each class carries retryability, ceiling and stable name, the classifier is total with no unknown class, core's two exceptions are mapped by their fields, a 403 on a segment with a healthy manifest is Transient.CdnEdge and on a manifest is not, the class → FailureCategory table is built and no telemetry code is touched, and the .api diff is committed. Two of #177's acceptance boxes are deliberately not ticked here: "every FaultScript fault kind classifies to a named class, asserted through the harness" and "each hostile corpus entry that fails today classifies to a named class". This unit was scoped to the pure mapping — nothing acts on a classification yet, so a harness run would only catch an exception and hand it to the same function this test calls directly — and driving the fault corpus and the hostile corpus through a player is the phase's exit test, #184, where it is a criterion rather than a duplicate.

What was skipped, and why

No emulator or demo run. This unit has no playback path: the classifier is a pure function, so the Robolectric unit test is the end-to-end evidence. ./gradlew assemble check is green on the full repository.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS

`superplayer-resilience`'s first code, and the vocabulary every issue after
it reads. `FailureClass` is PRD.md §3.3's sealed taxonomy — public, naming no
Media3 type — and each class answers the three questions ADR-0011 rule 1 asks
of it: whether retrying the same bytes can help, the highest `FallbackRung`
the ladder may climb for it, and the stable name a log line, a bug report and
a warehouse row share. A ceiling rather than an itinerary, because which
lower rungs are worth attempting is the ladder's (#181); `FallbackRung` is a
type rather than a number because "3" at a call site says nothing about which
rung 3 is. The one-to-one row deriving telemetry's coarse `FailureCategory`
is on the class (rule 3), so nothing keeps a second taxonomy; wiring it into
`PlaybackFailure` is #183's.

`ErrorClassifier.classify` is total and has no unknown class to fall into.
It reads, strongest evidence first: what core already concluded, mapping
`StaleLivePlaylistException` by the `likelyCause` it names and
`LiveWindowTooShortException` by its verdict, re-deriving neither (rule 4);
then the load that failed, where the `LoadKind` stamp a player with
resilience carries is what tells a refused segment from a refused manifest,
which is the whole of `Transient.CdnEdge`; then the engine's error-code band,
the only `when` over `errorCode` the repository may contain. The
fall-through is `Transient.Network` and never `Fatal.Unsupported`, which is
reached only from a code that says *unsupported*: an unrecognised code is
likelier to be a transfer that can be retried than content that can never
play, and calling it fatal would end sessions the ladder could rescue.

The test is pure — there is nothing to play to classify a failure — and
asserts totality over the whole code space rather than over a corpus, which
is what "zero unclassified errors" means as a property of the function.
Robolectric only for the two Android types the evidence is made of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
@ramesh130
ramesh130 merged commit 4d5df7d into main Sep 16, 2026
1 check passed
@ramesh130
ramesh130 deleted the issue-177-error-classifier branch September 16, 2026 03:00
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.

Build ErrorClassifier: one taxonomy, mapping core's typed failures rather than re-deriving them

1 participant