Second on-device feedback round: eight fixes, and a measured retry policy

The ghost bookcase was an inset bug, not a data bug. LocationsScreen's list
branch dropped the Scaffold's innerPadding while its empty-state branch applied
it, so the first bookcase row rendered under the top app bar and was invisible.
Both of the user's bookcases were always real; they just could not see the first
one, so they made a second. Every other screen was checked for the same class of
bug — Locations was the only one.

Metadata lookup is now designed against a measurement rather than a guess
(docs/METADATA-SOURCES.md § "Measured again 2026-09-09"):

  - Google Books keyless is dead for everyone, permanently. The user's
    residential-IP test returned a quota error naming a shared anonymous PROJECT,
    not an IP, so the earlier "your phone may well get answers" guess is wrong and
    is now marked CORRECTED in place. Because combine() turns any Failed-with-no-
    Found into Unavailable, that standing failure meant every Open Library hiccup
    surfaced as "one or more sources couldn't be reached". The API key is
    deliberately deferred by the user; this commit leaves the source broken.

  - Our own timeouts were manufacturing failures. Over 30 live requests, 13%
    failed — all fast TLS resets under 2.5s — while successes ran to a median of
    4.3s and a max of 22.0s. Two of 26 successes exceeded the old 12s callTimeout,
    so ~8% of lookups that were about to work were cancelled and reported as
    unreachable. Timeouts are now 25s/20s/20s.

That asymmetry (cheap failures, expensive successes) is what RetryPolicy encodes.
It retries TRANSPORT and SERVER_ERROR with a 250ms/750ms jittered backoff, and
deliberately does not retry TIMEOUT (the budget is already spent) or RATE_LIMITED
(hammering a quota is how an intermittent block becomes a permanent one — this
project's IP has already been refused outright once during research).

SourceResult.Failed now carries a FailureKind alongside its human reason, and the
reason names the specific failure ("tls connection reset, 3 attempts") instead of
a generic "network error". That string was already threaded to the UI and dropped
on the floor; LookupFailedSheet now renders it. It is the only diagnostic channel
we have from a real phone, so nothing may parse it.

Also from the same feedback round:
  - Grouped ModalBottomSheet shelf picker, replacing two near-duplicate flat
    dropdowns that listed every bookcase x shelf pair. Sections per bookcase,
    empty bookcases say so, and the most recently used shelf is pinned on top.
  - The recent shelf persists across sessions (SettingsStore.LAST_SHELF_ID) and is
    cleared on sign-out. It is offered, never pre-selected: the user weighed that
    and chose one tap over the risk of silently mis-shelving a book.
  - Locations dialogs and the manual-ISBN dialog auto-focus their first field.
  - The library filter menu offers "Add a bookcase to enable filtering" instead of
    a lone "All books" that is already the active state and cannot be changed.
  - The Locations button is Material Symbols' "shelves" (a bookcase) instead of
    Warehouse (a barn). material-icons-extended 1.7.8 has no bookcase glyph.
  - The scan sheet drops "you can lower the book" — the ISBN echo already says it.

assembleDebug exit 0; testDebugUnitTest 172 tests, 1 skipped, 0 failures (was
138); verifyPaparazziDebug exit 0; assembleRelease exit 0, signed with the real
release key; zero "always 'false'" warnings on a --rerun-tasks rebuild.

Three soft spots are recorded in docs/HANDOFF.md and are NOT verified: the
ghost-bookcase Paparazzi snapshot renders a lookalike of the screen rather than
the screen, the auto-focus calls swallow their own failure and no emulator exists
here, and the picker opens as a sheet stacked on the save sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSnVqWdiQNEcPFRq1hGZAi
This commit is contained in:
Sprite
2026-09-09 17:45:09 +00:00
co-authored by claude
parent dd3a61fc33
commit d6d02f788c
44 changed files with 1740 additions and 127 deletions
+77
View File
@@ -97,6 +97,10 @@ Worth saying plainly, because it bounds how much weight the numbers carry:
this host sharing a quota pool with other tenants; **your phone, on a
residential or mobile IP, may well get answers.** That's precisely why the
app needs to be able to tell us which it got.
> **CORRECTED 2026-09-09 — this guess was WRONG.** The user tested from a
> residential IP and got the same refusal, naming a shared *project* quota
> rather than an IP one. See "Measured again 2026-09-09" at the end of this
> file. Do not act on the sentence above.
- I don't know which three ISBNs you scanned. If you still have the books to
hand, those three numbers are worth more than another 60 sampled ones.
@@ -215,3 +219,76 @@ One thing worth deciding separately: 17% of books legitimately have no cover art
anywhere. The placeholder now looks deliberate rather than broken, but if you
want covers on everything, that's a different feature — photograph the book,
store it as the cover — and not a metadata-source problem at all.
## Measured again 2026-09-09, after the user tested from a residential IP
Two things were settled that the first round could only guess at.
### Google Books keyless is dead everywhere, not just from this box
The user ran the app's exact Google Books call from their home connection and got:
Quota exceeded for quota metric 'Queries' and limit 'Queries per day'
of service 'books.googleapis.com' for consumer 'project_number:624717413613'
That names a **Google Cloud project, not an IP**. Every keyless caller on the
internet is billed to that one shared anonymous project and its daily quota is
exhausted. So:
- The earlier caveat — "your phone, on a residential or mobile IP, may well get
answers" — is **WRONG**. Delete it from your mental model. It was tested and it
is not true.
- Backoff cannot help. This is a daily quota, not a per-second rate limit.
- A free API key is the only fix, and it is a complete one: it moves the app into
its own project with its own quota (free tier 1,000 req/day).
The knock-on is the part that actually hurt the user. `MetadataRepository.combine`
turns "any source Failed, none Found" into `Unavailable`. Google Books is a
PERMANENT standing failure, so **every** Open Library hiccup became `Unavailable`.
The app has effectively been single-sourced this whole time while reporting
failures as though two sources had been consulted.
**The user has deliberately deferred the API key.** Do not implement it unasked.
### Open Library: 13% failure, and our own timeout was manufacturing more
30 requests, the exact call `OpenLibraryClient` makes, 1.5s apart, from the sprite:
| | |
|---|---|
| failure rate | **13%** (4 of 30) |
| every failure | curl exit 35 — TLS-stage `Connection reset by peer` |
| failure latency | 0.23s, 0.31s, 0.59s, 2.46s — **all fast** |
| success latency | median **4.3s**, p75 6.0s, p90 **9.2s**, max **22.0s** |
| successes over the old 12s callTimeout | **2 of 26 (8%)** |
Two conclusions, and they point in opposite directions:
1. **Failures are cheap and transient**, so retrying is nearly free. 13% → ~1.7%
at two attempts → ~0.2% at three. This is why `RetryPolicy` exists and why its
backoff is milliseconds rather than the conventional seconds.
2. **Successes are slow and long-tailed**, and the app's own
`callTimeout(12s)`/`connectTimeout(10s)` were cutting off roughly 8% of
lookups that were about to succeed — then reporting them to the user as
"couldn't be reached". The app was generating a meaningful share of its own
failures. Timeouts are now 25s/20s/20s, above the 22.0s worst observed success.
The asymmetry is the whole design: a short timeout buys nothing on the failure
path (failures return in under 2.5s regardless) and costs real successes on the
slow path. That is also why `RetryPolicy.isRetryable` refuses to repeat a
TIMEOUT — a timeout means the budget was already spent, and the evidence says
slow requests mostly succeed if you let them finish.
Connection reuse is visible in the data and matters in real use: cold connects
ran 2-19s while warm ones ran 0.07s. OkHttp pools connections for 5 minutes, so
scanning a box of books in sequence stays on the fast path after the first book.
### What still is not known
- All 30 requests came from this datacenter IP. The user's phone may see a
different failure rate. The reason string now shown on the scan sheet
(`SourceResult.Failed.reason`, e.g. "tls connection reset, 3 attempts") is how
we find out — it is the only diagnostic channel we have from a real device.
- The user's own 3-request sample showed 2 failures. That is consistent with 13%
(p ~ 5%) but does not confirm it. If their phone reports "3 attempts" often,
their network is worse than this one and the retry count deserves revisiting.