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:
1 parent
d6d02f788c
commit
486f6ebc48
11 files changed
+662
-37
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user