Skip to content

fix(hardcover): stop asking with a rejected API token (#3101) - #3160

Merged
vavallee merged 4 commits into
mainfrom
fix/3101-rejected-token-hold
Oct 11, 2026
Merged

vavallee merged 4 commits into
mainfrom
fix/3101-rejected-token-hold

Conversation

@vavallee

Copy link
Copy Markdown
Owner

Summary

#2699 made Hardcover work stop on a daily quota hold, but a 401 never reached any of it: classifyHTTPError worded the "token rejected" message and observe only read rate limit headers. A bulk import with a rejected token therefore still sent one doomed request per row. This records the rejection as a hold keyed by the token, so the first 401 stops every caller and nothing asks again until the token changes. Closes #3101.

Design

Question Choice Why
What counts 401, or a 403 whose body is an authentication failure (invalid_token, unauthorized, invalid_grant, "Unable to verify token") Narrower than isTokenRejection: insufficient_scope, access_denied and invalid_request can describe one query, and the refused ilike operator 403 and "Token bucket exhausted" never count. 5xx never counts. GraphQL errors inside a 200 are not widened in.
Error type new metadata.TokenRejectedError (not DailyQuotaError), Unwraps to the classified upstream error on the first call, no reset time, never carries the token A caller must not tell a user to wait for a reset that never comes.
Stop points metadata.IsProviderHold / ProviderHold replace every errors.As(&DailyQuotaError) One predicate, so a sibling consumer cannot be missed.
Duration until the token changes (new token, new HMAC key), capped at one hour After the cap the next request is a probe: success clears the hold, a 401 renews it. Costs one request an hour for a token that stays bad, and heals a 401 that was Hardcover's fault or a token restored on their side without a restart or a save. 24h would leave that user without Hardcover for a day.
Test connection WithRejectedTokenProbe(ctx) always sends its request; a success clears the hold The daily quota hold still blocks the button, as in #2699.
Persistence own setting auth.hardcover_rejected_holds (secret, hidden from the settings API), same HMAC key, same retry of a failed write before admitting anything, corrupt row logged and ignored The daily row keeps its format so a downgrade still reads it.
Concurrency hold check and recording under the store mutex, never across HTTP or a settings write; writes ordered by writeMu Same discipline as #2699. A 401 for a token that is already held extends in memory and skips the write.

Every Hardcover call funnels through query(), so the primary, an enricher, the cover and ISBN paths, the list syncer and the list browse all refuse fast with no per-site wiring. Aggregator.CheckQuota stays primary only: a held enricher fails its own calls without a request and the primary's answer still counts.

Call sites

Matched DailyQuotaError and now use IsProviderHold / ProviderHold: internal/metadata/aggregator.go (ISBN walk), internal/migrate/outage.go (import breaker), internal/migrate/authors.go (per row reason), cmd/bindery/discovery_wiring.go (discovery Backoff). cmd/bindery/main.go reads the hold for GET /api/v1/system/status and reports hardcoverTokenRejected.

Stop on any CheckQuota error, so they pick the new error up unchanged and each has a test: ABS import, scheduler refresh, CSV import, Readarr import (same walk as CSV), catalogue sync (internal/api/authors.go), list syncer (through the client).

Surfacing

Settings, API Keys shows a notice while the token is rejected (settings.general.hardcoverTokenRejected, en.json only). The import result carries Hardcover rejected the API token ... as the failure message. Docs: DEPLOYMENT.md "Hardcover rejected token pause", API.md status field.

Checklist

  • Tests added or updated
  • Doc-update gate cleared (see commits skill: docs/, README.md, godoc, Helm values)
  • Wiki pages updated if user-facing behaviour changed

Test plan

  • go build ./..., go vet ./internal/... ./cmd/...
  • golangci-lint run --concurrency 2 on internal/metadata/..., internal/api, internal/migrate, internal/abs, internal/scheduler, internal/hardcoverlistsyncer, cmd/bindery
  • go test -p 2 -count=1 on the same packages
  • go test -race -count=1 ./internal/metadata/hardcover/
  • cd web && npx tsc --noEmit, npx eslint on the touched files, npx vitest run src/pages/SettingsPage.test.tsx
  • Regression for the symptom: TestCSVImportWithRejectedTokenAsksHardcoverOnce sends a 10 row CSV import through a real client and counts transport requests and expects 1 request and a stopped import; with the recording removed (the pre-fix behaviour) the import runs on past the first 401 and the test fails
  • 17 mutations of the new guards each fail at least one test (recording, hold check, probe flag, 403 narrowing, 5xx, clear on success, persistence, token keying, one hour cap, test button, each stop point, secret setting, corrupt row)
  • Not run: make smoke, the /system/status closure in cmd/bindery/main.go (inline in main, read only)

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

A 401 from Hardcover was classified for the error message but never
recorded, so every row of a bulk import sent its own doomed request.

A rejected token (401, or a 403 whose body is an authentication failure)
now records a hold keyed by the token fingerprint, in its own private
setting with the same HMAC key as the daily quota holds. Every call
through any client sharing the store, primary, enricher, cover, ISBN and
list paths included, is refused without a request. The hold ends when the
token changes, after one hour (one probe request, then renewed or
cleared), on a successful response, or when Test connection succeeds. Test
connection always sends its request so a fixed token can be retested.

The new metadata.TokenRejectedError is its own type, not DailyQuotaError,
and every stop point (aggregator ISBN walk, import breaker, per row
reason, discovery backoff) asks metadata.IsProviderHold instead of
matching one type. System status reports hardcoverTokenRejected and
Settings shows a notice.

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
@codecov

codecov Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.60331% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/metadata/hardcover/daily_quota.go 86.90% 7 Missing and 4 partials ⚠️
internal/metadata/daily_quota.go 88.88% 1 Missing and 1 partial ⚠️
internal/metadata/hardcover/client.go 87.50% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

vavallee and others added 3 commits October 11, 2026 16:21
… status flags (#3101)

A 401 whose body is an HTML or plain text page came from a proxy or CDN in
front of Hardcover, which answers a bad token with a JSON invalid_token body.
Pausing on the page would lock a valid token out for an hour, so only a JSON
or empty 401 starts the hold. Cases added for a proxy page, a gateway text
body and a Cloudflare challenge.

GET /api/v1/system/status computed its two Hardcover fields inline in main
with no test. The logic moves to hardcoverHoldStatus, which is covered for no
hold, a daily pause, a rejected token (also joined with a write failure), a
wrapped error and an unrelated error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
#3101)

The precedence in DailyQuota.Check had no test: making the daily hold win
survived every existing test, so a rejected token over its quota would have
promised a reset that cannot help.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@vavallee
vavallee merged commit 41a749c into main Oct 11, 2026
41 checks passed
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.

A rejected Hardcover token is retried on every call during a bulk import

1 participant