Skip to content

Tell a viewer and a data team the same thing about a failure nothing rescued - #197

Merged
ramesh130 merged 1 commit into
mainfrom
issue-183-typed-error
Sep 16, 2026
Merged

ramesh130 merged 1 commit into
mainfrom
issue-183-typed-error

Conversation

@ramesh130

@ramesh130 ramesh130 commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Rung 6 of ADR-0011's ladder, the top: a failure every rung below has declined ends the session on a typed, actionable error, and telemetry reports the classifier's answer instead of an unexplained I/O error.

Closes #183
Closes #86

What is built

Half one — the error. SuperPlayerError (core, public, internal constructor) is PRD.md §3.2's shape — a cause class, a userMessageKey, isRetryable — plus ADR-0011 rule 10's three: the rungs tried, the position reached, and the likely party where core's own detection named one (StaleLivePlaylistException.likelyCause survives the mapping). It arrives as Player.Listener.onPlayerError's PlaybackException.cause. No new listener, no new callback.

Half two — telemetry (#86). PlaybackFailure gains a nullable classification, the class's stable name. QoeCollector asks the player (SuperPlayer.classify(error)) and keeps no taxonomy of its own, in either direction. category stays six values, derived from the class where there is one and from the error-code band where there is not. LogcatSink and SessionTraceRecorder append the classification only where there is one.

Argued decisions

The version bump: yes, and the issue's reason was not quite the reason. ADR-0011 rule 3 owes a bump only where the class and the band-derived bucket disagree and the bucket changes to match. They do, in five places, and the table is in docs/telemetry-schema.md's new Release notes section:

Failure Band Class Why the class wins
Playlist frozen at the origin NETWORK SOURCE (Content.SegmentGap) the transfer worked; the segments were never published
Read past the end of a segment NETWORK SOURCE the object is shorter than the manifest described
ERROR_CODE_DECODING_FORMAT_UNSUPPORTED DECODER SOURCE (Fatal.Unsupported) nothing is wrong with this device's decoding
5xxx / 7xxx renderer bands RENDERER DECODER the remedies are the decoder rungs; RENDERER goes empty with resilience attached
Any unrecognised code (1xxx, session codes) UNKNOWN NETWORK rule 2 leaves nothing unclassified

So SCHEMA_VERSION is 2. Worth recording: ADR-0011's own Consequences predicted this would first bite on StaleLivePlaylistException, and that turned out half right — the INTERMEDIARY_CACHE variant is Transient.CdnEdge, whose row is NETWORK, the same as the band. It is the ORIGIN variant that disagrees. Noted in an addendum at rule 3.

code keeps its meaning, against #183's own acceptance criterion. The issue expects the classification to arrive as PlaybackFailure.code. ADR-0011 rule 3 is narrower and says classification; the ADR wins, and the argument is the rule's own: which code the engine raised and what SuperPlayer made of it are two facts, a pipeline needs the first to find the failure in a logcat, and collapsing them deletes the engine's answer rather than adding SuperPlayer's. Recorded in the rule 3 addendum and in the schema doc. The acceptance criterion is met in substance — a classified failure is identified as such through the harness — but on classification, not on code.

Telemetry reads through the facade rather than becoming a sixth friend of core. A collector watches Media3's analytics, so the exception it is handed is the engine's with nothing of this on it. SuperPlayer.classify is public for the reason exoPlayer is public: superplayer-telemetry reaches core as a consumer does (ADR-0008), and the alternative — a sixth Kotlin friend — is a bigger architectural claim than one query method for a module that deliberately uses the public escape hatch.

The delivered exception is core's, not the engine's. A cause is fixed at construction and the engine constructs the one it raises, so the delivery is a PlaybackException with the engine's code and message, SuperPlayerError as its cause, and the engine's exception under that — nothing lost, a consumer switching on errorCode unaffected. getPlayerError() returns the same object, which narrows rule 10's addendum (property not special-cased for withholding) rather than contradicting it: that addendum's reason — a consumer reading the property sees what their listener was told — is why it must be substituted.

The classification is taken for every surfaced failure, eagerly. Not only at rung 6: rule 3 has telemetry report the class of a repaired failure too, and the position on the answer must be read before a rung moves it.

The seam grew two members. PlayerStateRungs.typedErrorFor(error, positionMs) and forgetClimb() — the latter the one member that is not a question, because the rungs tried are the ladder's record and when they stop being this content's is core's fact. Neither needs a slot core calls, so rule 13's addendum's test is answered. PlayerStateLadder is now per player (a ClimbRecord is a player's own history; a shared one would report one feed row's retries on another's error).

Tests

TypedErrorPlaybackTest (new, superplayer-resilience, through PlaybackHarness, nothing past the facade):

  • a session nothing rescued ends on the three-part shape, with RETRY_SAME_URL among the rungs tried;
  • the listener and player.playerError are handed one object, with the engine's code preserved;
  • a frozen live playlist behind a cache that ignores no-cache is Transient.CdnEdge with likelyCause = INTERMEDIARY_CACHE, and its PlaybackFailure carries classification = "Transient.CdnEdge", category = NETWORK, code still errorCodeName — Telemetry reports a typed failure as an unclassified I/O error #86's acceptance;
  • counted, not assumed: a player without resilience gets Media3's error unchanged, classify answers null, and its PlaybackFailure is byte for byte what it was before Phase 5.

SuperPlayerDecoderRecreationTest now asserts the whole question order core puts about one failure — forgetClimb, typedErrorFor, opensNextSource, recreatesDecoder — which pins "the classification is taken before any rung moves the position".

superplayer-resilience gains a test-only dependency on superplayer-telemetry (phase 5 on phase 2), the same direction and reason as the existing superplayer-cache one.

Golden traces

./gradlew updateGoldenTraces, run alone: no diff. The four traces are core-only sessions with no failure line in them, and the trace's classification field is appended only where there is one — so ADR-0011 rule 14's "a core-only golden trace that changes is a violation whatever the diff says" is satisfied by there being nothing to review.

Two-axis self-review

Standards. Clean-room citations kept (// ref: on the RFC statuses, // spec: unchanged); ADR-0011 rules 1–3, 10, 13, 14 followed and the two shape decisions recorded as addenda rather than as undocumented exceptions; docs/api-surface.md honoured — updateApiSurface run, diff committed (PlaybackFailure gains a component, SuperPlayer.classify and SuperPlayerError appear, FailureClass gains userMessageKey and its key constants); docs/testing.md honoured — the test drives the public API only, and an early draft that read player.exoPlayer.playerError for the engine's code was rewritten to read it off the facade. Smells considered: the two lambdas on reportingSourceAs are a mild data clump, kept because both concern the same callback pair and are documented together; classify memoizes and so is a query with a side effect, documented where withholdsFromConsumer already is; no second when over an error code was added anywhere.

Spec. Every acceptance box of #183 and #86 is met except the one on code, which is deliberately not met and argued above. PlaybackFailure.classification is null without resilience; the coarse category is unextended; the schema doc states what code means, how it relates to errorCodeName and what classification is, and carries the release note.

Not run, and why

Emulator and demo runs skipped (host memory, per the batch instructions). benchmark/ is a separate build and not in check; it is unaffected by construction — PlaybackFailure's new field is defaulted, and StockTelemetryAgreementTest attaches its QoeCollector to a SuperPlayer built without resilience, so both collectors still derive identical events.

CI

Dispatched on issue-183-typed-error (CI is workflow_dispatch: only; nothing runs on a pull request):
https://github.com/ramesh130/superplayer/actions/runs/35066543083 — success.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS

…rescued

Rung 6 of ADR-0011's ladder, the top, and the half of "zero unclassified
errors" that faces outwards. It closes #86 as rules 3 and 4 said it would.

A failure every rung below has declined now ends the session on
`SuperPlayerError`: `PRD.md` §3.2's shape — a cause class, a `userMessageKey`
and `isRetryable` — plus rule 10's three, the rungs tried, the position
reached and the likely party a `StaleLivePlaylistException` named. It arrives
where errors already arrive, as the `cause` of the `PlaybackException`
`onPlayerError` carries, with no new listener and no callback. The engine
cannot carry it — a cause is fixed when an exception is built — so the
delivered exception is core's, with the engine's own code and message and the
engine's exception underneath, and `player.playerError` hands back the same
object so a consumer reading the property is not told a different story.

The other half is telemetry, and it reads the classifier rather than keeping
one: `PlaybackFailure.classification` is the class's stable name, asked of the
player through `SuperPlayer.classify` because a collector watches Media3's
analytics rather than the facade and `superplayer-telemetry` is not a friend
of core. `category` stays six values and is derived from the class where there
is one; `code` stays `errorCodeName`, because which code the engine raised and
what SuperPlayer made of it are two facts and a pipeline needs the first to
find the failure in a logcat.

That derivation is what moves `TelemetryEvent.SCHEMA_VERSION` to 2. The class
and the band disagree for five kinds of failure — a playlist frozen at the
origin, a read past the end of a segment, an unsupported format, the renderer
bands, and every code the band did not recognise — and
`docs/telemetry-schema.md`'s release note is the table of them. An added field
would not have moved it; a changed bucket does.

The rungs tried are a per-player `ClimbRecord` written where a rung takes a
failure on and cleared when core says the content changed, so a new programme
is not reported with the last one's history.

A player without resilience classifies nothing, reports null, buckets off the
band and receives Media3's own error unchanged, which is counted rather than
assumed; the golden traces did not move.

Closes #183
Closes #86

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeTWwmK1KcFMrfSAre23GS
@ramesh130
ramesh130 merged commit cfe0239 into main Sep 16, 2026
1 check passed
@ramesh130
ramesh130 deleted the issue-183-typed-error 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.

Rung 6: surface a typed, actionable error, and let telemetry report the classification Telemetry reports a typed failure as an unclassified I/O error

1 participant