Use the Google Books API key: the fallback source actually answers now

The user obtained a restricted Google Books key. Keyless requests 429 for every
caller on the internet — all anonymous traffic bills to one shared Google Cloud
project whose daily quota is permanently exhausted — so the documented fallback
has never once answered. Because MetadataRepository.combine turns "a source
failed, none found" into Unavailable, that standing failure meant every Open
Library hiccup reached the user as "couldn't be reached". The app has been
effectively single-sourced since it was written.

Build plumbing reads GOOGLE_BOOKS_API_KEY from local.properties (gitignored),
falling back to the environment and then to empty. A blank key is a supported
state: a fresh clone still builds a working app that falls back to the keyless
endpoint, rather than failing to build.

GoogleBooksClient appends the key only when non-blank, building the URL with
HttpUrl.Builder in a pure requestUrl() so it is testable without a socket. The
key is scrubbed from SourceResult.Failed.reason before that string can reach the
scan sheet — it is rendered to the user and is our only diagnostic channel from a
real phone, and some okhttp/JDK IOExceptions embed the full request URL in their
message. Defensive, not a response to an observed leak.

Resolves the RATE_LIMITED decision parked in RetryPolicy's KDoc: a keyed 429 is
the short per-user rate limit and gets exactly one retry, honouring Retry-After
capped at 2s. A keyless 429 is still the dead daily quota and is still never
retried.

Verified against the live API, not only offline: both ISBNs that failed on the
phone (9781883937386, 9781883937676) plus a control return HTTP 200, in the
percent-encoded URL shape HttpUrl actually produces. Both books are in Google
Books, so the restored fallback now covers precisely the Open Library TLS-reset
failure that broke those scans.

189 unit tests (was 172), 0 failures; Paparazzi unchanged; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7WHnTx2Cso4VV245WDAJY
This commit is contained in:
Spriteandclaude committed 2026-09-11 23:34:43 +00:00
1 parent d6d02f788c
commit 486f6ebc48
11 files changed
+662 -37

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
You are implementing ONE feature in the Bookshelf Android app at ~/bookshelf.
READ FIRST, in this order:
1. ~/bookshelf/docs/SPEC.md — the authoritative contract, section "Book metadata
lookup". Do not contradict it and do not edit it.
2. ~/bookshelf/docs/METADATA-SOURCES.md — why this work exists. Read section
"Measured again 2026-09-09" in full; it is the evidence base for every
decision below.
3. The files you will change: data/metadata/GoogleBooksClient.kt,
data/metadata/MetadataRepository.kt, data/metadata/RetryPolicy.kt,
data/metadata/SourceResult.kt, AppContainer.kt, and their tests.
## Background — what is already done, do NOT redo it
The user has obtained a Google Books API key and put it in `app/local.properties`
as `GOOGLE_BOOKS_API_KEY=...`. That file is gitignored.
The ORCHESTRATOR has ALREADY done the build plumbing, and it is verified working:
`app/app/build.gradle.kts` reads that property (falling back to the environment
variable of the same name, then to empty string), enables the `buildConfig`
feature, and emits `BuildConfig.GOOGLE_BOOKS_API_KEY`. `assembleDebug` is green
and the generated BuildConfig carries the real key.
So: **the key is already available to Kotlin code as
`org.modg.bookshelf.BuildConfig.GOOGLE_BOOKS_API_KEY`.** You do not need to, and
MUST NOT, touch any build file. If you think you need to, stop and say so in your
report instead.
The key has been tested live from this machine against all three of these ISBNs —
9781883937386, 9781883937676, 9780140449136 — and returned HTTP 200 with the
right titles. The key works. Your job is to make the app use it.
## Why this matters (so you make the right call on ambiguity)
Keyless Google Books returns HTTP 429 to EVERY caller on the internet, forever:
all keyless traffic bills to one shared anonymous Google Cloud project whose daily
quota is permanently exhausted. That is a measured fact, not a guess — see
METADATA-SOURCES.md.
The knock-on is the part that actually hurt the user. `MetadataRepository.combine`
turns "at least one source Failed, none Found" into `Unavailable`. Google Books has
been a PERMANENT standing failure, so **every** Open Library hiccup surfaced to the
user as "one or more sources couldn't be reached". The app has effectively been
single-sourced this whole time while reporting as though two sources were
consulted. Making the key work restores the second source and, with it, the
honesty of `Unavailable`.
## What to build
### 1. GoogleBooksClient sends the key
Give `GoogleBooksClient` an `apiKey: String` constructor parameter, defaulting to
`""`. Append `&key=<apiKey>` to the request URL ONLY when the key is non-blank.
A blank key must keep today's exact behaviour (keyless request, which 429s). This
is not a hypothetical: a fresh clone of this repo has no `local.properties` key,
and its build must still produce a working app rather than a broken one.
URL-encode the key (`java.net.URLEncoder` or okhttp's `HttpUrl.Builder`). Prefer
`HttpUrl.Builder` — okhttp is already a dependency and it encodes query parameters
correctly by construction.
CRITICAL FOR TESTABILITY: there is no MockWebServer in this project and you must
not add one. Put URL construction in a pure, package-visible function that takes
the ISBN and returns the URL string, exactly the way `classify()` is already
factored out for the same reason:
internal fun requestUrl(isbn13: String): String
`fetch()` then calls it. That makes the whole feature unit-testable offline.
### 2. The key must never leak into anything user-visible or logged
`SourceResult.Failed.reason` is RENDERED ON THE SCAN SHEET and is our only
diagnostic channel from a real phone. A `reason` that carried the request URL
would put the API key on the user's screen and into any screenshot they send us.
- Never build a `reason` from the request URL.
- `SourceResult.fromException` derives its reason from exception type/message.
Some okhttp/JDK IOExceptions DO include the full URL in their message
(`java.net.UnknownHostException`, SSL errors, and okhttp's own
`java.io.IOException: Canceled` variants differ by platform). Add a redaction
step so that ANY reason string passing through has an API key replaced with a
placeholder before it can be stored or shown.
- Write a unit test that asserts the key does not appear in the reason for a
failure whose exception message contains the full keyed URL. Assert on the
literal key string you pass in — a test that only checks for "AIza" is not
good enough, because the test key you construct should not look real.
Do not log the URL anywhere either. (`metadataHttpClient` in AppContainer has no
logging interceptor today — do not add one.)
### 3. Wire the key through (AppContainer + MetadataRepository)
`MetadataRepository`'s convenience constructor `(httpClient, json)` builds both
clients itself. Add a `googleBooksApiKey: String = ""` parameter to it and pass it
to `GoogleBooksClient`. Keep the primary constructor (which takes the two clients)
as it is — the tests use it.
In `AppContainer`, pass `BuildConfig.GOOGLE_BOOKS_API_KEY` when constructing
`MetadataRepository`. That single line is the ONLY change you may make to
AppContainer.kt, and AppContainer.kt is the only file outside `data/metadata` you
may touch at all.
### 4. A keyed 429 is a different animal — make it retryable, once
`RetryPolicy.isRetryable` deliberately refuses to retry `RATE_LIMITED`, and its
KDoc explains why AND explicitly parks this decision for the wave you are now
doing. Read that KDoc before writing anything here.
The reasoning: today's keyless 429 is an exhausted DAILY quota that never clears
within a scanning session, so retrying is pure waste and risks turning an
intermittent block into a permanent one. A KEYED 429 is usually the per-user
per-100-seconds rate limit instead, which does clear, and is worth exactly one
more ask.
Implement it narrowly:
- `withRetry` gains a parameter, default `false`, that permits ONE retry of a
RATE_LIMITED failure — e.g. `retryRateLimitedOnce: Boolean = false`.
Open Library's call site keeps the default and its behaviour must not change.
- `GoogleBooksClient` passes `apiKey.isNotBlank()` for it. Keyless stays
unretried, which preserves today's measured-correct behaviour.
- One retry means one: a second 429 is returned to the caller, never a third
attempt, regardless of `MAX_ATTEMPTS`.
- Respect a `Retry-After` header if the response carries one, but CAP the wait
at ~2 seconds. The user is standing at a bookshelf. If the header is absent,
unparseable, or over the cap, use a short fixed backoff instead of honouring
it. Note this means `classify()` — currently `(httpCode, body)` — needs the
header value to reach the retry decision; thread it through in whatever way
keeps `classify` a pure function, and keep its existing tests passing.
- Unit-test all of it with the injected `sleep`/`nowMillis` hooks `withRetry`
already has. No test may sleep on the real clock.
Keep every existing guarantee in `withRetry` intact, including the "N attempts"
suffix on a surviving failure's `reason`.
### 5. Update the stale comments you invalidate
Several comments now describe a world that no longer exists — the KDoc at the top
of `GoogleBooksClient` says "No API key", its `lookup` KDoc explains why retrying
costs nothing today, and `RetryPolicy`'s RATE_LIMITED bullet parks the decision you
are implementing. Update each to describe the new behaviour. Do not leave a comment
that contradicts the code; this project treats that as a defect.
Do NOT edit docs/SPEC.md, docs/HANDOFF.md or docs/METADATA-SOURCES.md — the
orchestrator owns those.
## Constraints — these are hard
- Kotlin. Match the surrounding code's style, naming and comment density. These
files are heavily commented with the EVIDENCE for each decision, not with
restatements of the code. Read neighbouring files before writing and follow that
convention.
- DO NOT touch: app/app/build.gradle.kts, gradle/libs.versions.toml, or any other
build file. The plumbing is done and verified.
- DO NOT touch anything under data/local, data/remote, data/repo, data/prefs, or
any ui/ package. AppContainer.kt is limited to the one line in §3.
- DO NOT read, print, cat, grep or echo `app/local.properties` or any file that
might contain the key, and never paste a real key into a source file, a test, or
your report. Tests must use an obviously-fake key like "test-key-123".
- DO NOT git commit, git add, or git push. The orchestrator commits. Leave your
work in the working tree.
- Build with `./tasks/gw <task>` — NEVER `./gradlew` directly (tasks/gw is a
flock-serialized wrapper).
- RUN BUILDS IN THE FOREGROUND. Do not background a Gradle build and end your turn
saying you will report later — two previous workers did exactly that and could
never report. A full build here takes 1-3 minutes; just wait for it.
- There is no emulator on this box (no KVM). You cannot run the app.
## Definition of done
All of these must pass, and you must run them yourself and paste the real output:
./tasks/gw assembleDebug -> exit 0
./tasks/gw testDebugUnitTest -> exit 0. 172 tests pass today (1 skipped,
LiveSyncTest). The count must go UP and
nothing may regress.
./tasks/gw verifyPaparazziDebug -> exit 0. This change is not supposed to move
a single pixel; if a snapshot fails, you
changed something you should not have.
Required new tests, with REAL assertions:
- requestUrl includes `key=` with a non-blank key, and omits it entirely when
the key is blank.
- a key needing URL-encoding is encoded.
- the key is redacted out of a Failed reason built from an exception whose
message contains the keyed URL (§2).
- a keyed 429 is retried exactly once; a keyless 429 is not retried at all;
a second 429 ends it.
- Retry-After is honoured when short, capped when long, ignored when absent or
unparseable.
Assertion-free tests are a spec violation on this project.
Also: check the build log for Kotlin warnings on files you touched. A warning
reading "Check for instance is always 'false'" is NOT cosmetic — that exact
warning hid a bug that blanked every book cover in this app for months. If you see
it, you have written dead code; fix it.
## Report
End with a plain report covering:
- what you changed, file by file
- the verbatim tail of each of the three gradle commands
- the test count before and after
- anything you could NOT do, or did differently from these instructions, and why
- anything you noticed that looks wrong but was out of scope
Be honest. Workers on this project have over-claimed success before, and the
orchestrator independently re-verifies everything, so an inflated report only
wastes a round trip.