Adopt HTTPStatusError across all API clients; fix fatal-error classification - #354
Conversation
…ication Implements #325. Governing: ADR-0020, ADR-0026, SPEC error-handling REQ-ERR-003. - New internal/resilience package holds HTTPStatusError so provider and enricher clients can construct it without an import cycle (services imports providers); internal/services keeps a type alias so its API is unchanged. - Every provider/enricher HTTP client (Navidrome, Spotify provider + enricher, Last.fm provider + enricher, MusicBrainz, Fanart.tv, Lidarr) now wraps non-2xx responses in resilience.NewHTTPStatusError, so ClassifyError takes the typed path. - String matching kept only as a fallback, extended to match the formats clients actually emit ("returned status: 401" with colon, "lidarr api error: 401 - ...") via a fatal-status regex. Fixes the confirmed bug where revoked Navidrome credentials were classified retriable, retried forever, and never notified. - JSON/XML decode errors (json.SyntaxError, json.UnmarshalTypeError, xml.SyntaxError) now classify fatal per SPEC error-handling REQ-ERR-003 (unparseable response body = API contract change). - CalculateBackoff now uses 2^(consecutiveFailures-1) so the first retry waits ~30s, matching SPEC Scenarios 1-2 and ADR-0020 (30/60/120s); previously RecordFailure's increment-before-calculate produced 60/120/240s. - Tests: revoked-Navidrome-credential end-to-end test (fatal class, backoff stops, NotifyIfNeeded fires), real-world error-format classification tests, decode-error tests, DBNotifier email test with Navidrome's exact 401 format, spec-progression backoff tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review — PR #354Verdict: CHANGES NEEDED — the headline fix (revoked Navidrome credentials → fatal → notification, backoff stops) is correct, well-tested end-to-end, and I reproduced the green suite ( Blocking1.
Non-blocking2. 3. Fallback ordering: retriable substrings are checked before the fatal regex — 4. Regex looseness — 5. 6. Decode-errors-fatal: spec-sanctioned, carve-out verified. REQ-ERR-003 explicitly makes unparseable bodies fatal, so classifying 7. For the deferred REQ-BACK-001 spec amendment: note the spec also self-contradicts on the cap — jitter is applied after the 30m clamp (in both the spec formula and the code), so worst case is 37.5m, violating REQ-BACK-002's "MUST NOT exceed 30 minutes". Pre-existing; fold into the same amendment. Verified good
🤖 Posted on behalf of |
… wrap images.go Review feedback on PR #354 (FIX_NEEDED): - classifyHTTPStatus: HTTP 408 (Request Timeout) is now explicitly retriable — it is a timeout and REQ-ERR-002 requires timeouts to be retriable. Previously it fell into the default 4xx->fatal branch, which went live once clients started constructing HTTPStatusError. - classifyHTTPStatus: HTTP 404 is now deliberately retriable. Reverse proxies (e.g. Traefik) return transient 404s while a backend's route is dropped during a container redeploy; classifying that fatal would permanently stop sync with a misleading "reconnect credentials" email. The string-fallback regex drops 404 to stay consistent with the typed path. Both decisions pinned with typed-path tests. - ClassifyError string fallback: fatal patterns are checked before retriable substrings, so "status 401: ...timeout..." classifies fatal instead of retriable. Pinned with a test. - internal/enrichers/images.go: wrap the last unwrapped non-2xx site in resilience.NewHTTPStatusError, removing the latent string-fallback trap for #331/#333. Implements #325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review feedback addressed in 868e067 (independent review: #354 (comment)). BLOCKING 1a. 408 → retriable — 1b. 404 → retriable (deliberate decision) — went with the reviewer's suggested safer call. Rationale (documented in code and PR body): Traefik-style reverse proxies return transient 404s while a backend's route is dropped during container redeploys; fatal would permanently stop sync with a misleading "reconnect credentials" email. A genuinely wrong base URL degrades to retrying at the 30-minute cap, and malformed-config detection belongs to config validation (REQ-ERR-003), not per-request classification. To keep typed and fallback paths consistent, 404 was also removed from the fatal string regex (the one pre-existing RECOMMENDED
FYI items: the REQ-BACK-001 jitter-after-clamp self-contradiction is now listed in the PR body's Deferred Design Doc Updates section, alongside a note suggesting REQ-ERR-002 enumerate the 404/408 decisions. Verification: 🤖 Posted on behalf of |
Delta re-review — commit 868e067Verdict: LGTM — all four findings addressed correctly; independently re-verified on a detached checkout of
No new issues introduced by the delta. The remaining items from my first review were explicitly non-blocking (lidarr_submitter wrap → #331; REQ-BACK-001/002 spec amendment → deferred doc change). Good to merge. 🤖 Posted on behalf of |
What / Why
Part of epic #318.
internal/services/resilience.godefined a typedHTTPStatusErrorthat no client constructed, so error classification ran entirely on string matching — and the fatal patterns ("status 401") did not match Navidrome's real format ("navidrome API returned status: 401", with a colon). Revoked Navidrome credentials were classified retriable, retried forever on capped backoff, and never triggered the fatal toast or the ADR-0026 sync-failure email.Governing artifacts: ADR-0020 (exponential backoff and circuit breaker), ADR-0026 (sync-failure email notifications), SPEC error-handling REQ-ERR-003.
Changes
internal/resiliencepackage containingHTTPStatusError+NewHTTPStatusError. This is required becauseinternal/servicesimportsinternal/providers, so providers/enrichers cannot importinternal/serviceswithout an import cycle. The package location matches the spec's implementation note ("new struct ininternal/services/orinternal/resilience/").internal/serviceskeepsHTTPStatusErroras a type alias and re-exports the constructor, so all existing call sites and tests are unchanged.internal/enrichers/images.go) are wrapped inresilience.NewHTTPStatusError.Error()delegates to the inner error, so log output and message-based behavior are byte-for-byte unchanged.ClassifyErrorprefers the typed path; string matching is now explicitly a fallback and extended (via a(status|error):? *(401|403)regex) to match the formats clients actually emit, including Navidrome's colon variant and Lidarr'sapi error: 401 - ...format. In the fallback, fatal patterns are checked before retriable substrings so a fatal status embedded in a message wins even when the appended response body contains a retriable-looking word (e.g.status 401: ...timeout...).classifyHTTPStatus(this branch went live once clients started constructing the typed error, so each 4xx is now deliberate):json.SyntaxError,json.UnmarshalTypeError,xml.SyntaxError) classify fatal (unparseable response body = API contract change) instead of falling through to default-retriable. Truncated-body I/O errors (io.EOF/ErrUnexpectedEOF) deliberately remain retriable — those indicate transient network truncation, not a contract change.CalculateBackoffnow uses2^(consecutiveFailures-1), so withRecordFailure's increment-before-calculate the first retry waits ~30s, then ~60s, ~120s — matching SPEC error-handling Scenarios 1-2 and ADR-0020's consequences section ("30s, 60s, 120s, ... up to 30 minutes"). The previous code produced 60/120/240s. No spec amendment needed for the scenarios; see deferred notes below on REQ-BACK-001 wording.Test evidence
make test(full suite) passes;go test ./internal/services/... -race -count=1passes.gofmt -l .clean;go vet ./internal/...clean.grep -rn "NewHTTPStatusError" internal/providers internal/enrichersshows adoption in every client (25 call sites including the shared image downloader).TestSyncer_RevokedNavidromeCredentials_FatalStopsRetryAndNotifies— end-to-end: a 401 formatted exactly as the Navidrome client emits it classifiesErrorClassFatal, the provider is not called again on the next sync (backoff stops), andNotifyIfNeededfires exactly once.TestSyncer_LegacyNavidromeErrorString_FallbackClassifiesFatal— same guarantees through the string fallback for unwrapped errors.TestDBNotifier_SendsOnRevokedNavidromeCredentials— the realDBNotifiersends the ADR-0026 email for both the typed error and the legacy string format.TestClassifyError_RealWorldClientFormats— fatal for every real client format (Navidrome colon, Last.fm, Lidarr, Fanart.tv); 5xx/404 variants of the same formats stay retriable.TestClassifyError_Transient404_Typed,TestClassifyError_RequestTimeout408_Typed,TestClassifyError_FatalPatternWinsOverRetriableSubstring— pin the review-driven decisions.TestClassifyError_DecodeErrorsFatal,TestCalculateBackoff_SpecScenarioProgression,TestBackoffManager_FirstFailureDelayMatchesSpec.Deferred Design Doc Updates
delay = min(baseDelay * 2^consecutiveFailures, maxDelay), which literally contradicts the spec's own Scenarios 1-2 (first failure → ~30s implies2^(n-1)whennis the post-increment failure count). The code follows the scenarios and ADR-0020. Suggest amending REQ-BACK-001 to2^(consecutiveFailures-1)or clarifying thatconsecutiveFailuresin the formula means failures before the current one.jitterFactorafter themin(..., maxDelay)clamp, so at the cap the actual delay ranges up to 37.5 minutes, while REQ-BACK-002 says the delay "MUST NOT exceed 30 minutes regardless". The code (and pre-existing tests) apply jitter after the clamp per the REQ-BACK-001 formula. Suggest amending REQ-BACK-002 to "MUST NOT exceed 30 minutes before jitter" or clamping after jitter in a follow-up.(Spec files are protected paths — not edited here.)
Closes #325
Refs: #318, ADR-0020, ADR-0026, SPEC error-handling
🤖 Posted on behalf of
@joestumpby Claude.