Found live during the Open Library outage on 2026-09-04.
Symptom
While Open Library was unreachable, GET /v1/api/opds returned this:
{
"metadata": { "title": "Lenny Catalog", "numberOfItems": 0 },
"links": [ ..., { "rel": "next", "href": ".../opds?offset=50&limit=50" } ]
}
Zero publications, numberOfItems: 0 — and a rel=next link. The library held 96 items the entire time.
The two fields contradict each other: numberOfItems says the collection is empty, while rel=next says there is another page to fetch. Both are emitted from the same response.
Cause
opds_feed falls back to LennyDataProvider.empty_catalog(...) when Open Library is unreachable (lenny/core/api.py, the RequestException/HTTPError handler). That fallback is handed the real paging context — page["total"] from Item.count() — but _lenny_empty_catalog (lenny/core/api.py:138-144) hardcodes:
while _lenny_catalog_links computes rel=next from the total it was given (offset + limit < page["total"]). So during a fallback the count is a literal 0 and the paging links describe a 96-item collection.
Incidentally this is what let me measure the DB count from outside during the outage: rel=next was present for limit<=95 and absent for limit>=96, pinning Item.count() at exactly 96 while the feed insisted it was empty.
Why this matters more than it looks
modified_since + rel=next + metadata.modified were added in #200 specifically so Open Library's BookWorm harvester can harvest this feed incrementally (internetarchive/openlibrary#13241). That harvester is the primary consumer.
A harvester that reads numberOfItems: 0 during any Open Library blip concludes the library has no books. Depending on how it reconciles, that is a plausible path to it pruning all 96 records from the registry — triggered not by a Lenny bug but by a transient upstream outage, and self-inflicted by a hardcoded zero.
This is the same defect class as #203 — numberOfItems not reconciled against what is actually emitted — but far more severe: not one book missing, but the entire catalogue, and reachable any time Open Library has a bad minute.
Suggested fix
empty_catalog should report the real total when it has one:
- When
page is supplied, take numberOfItems from page["total"] rather than hardcoding 0. An empty page of a non-empty collection is a legitimate state and should be representable.
- Decide deliberately what an OL-unreachable feed should say, and make the two fields agree. If the honest answer is "I cannot enumerate the collection right now", a
503 is a better contract than a 200 describing an empty library — a harvester retries a 503 and does not prune on it.
The second point is the important one. A 200 that says "empty" is indistinguishable, to a consumer, from a library that genuinely has nothing — which is precisely why the outage was able to look like a data-loss event from the outside.
Reproducing
Block egress to openlibrary.org from the API container (or point OL at an unroutable host) and request /v1/api/opds with any local items present. Observed on 0.2.15; the fallback path is unchanged in 0.2.16.
Found live during the Open Library outage on 2026-09-04.
Symptom
While Open Library was unreachable,
GET /v1/api/opdsreturned this:{ "metadata": { "title": "Lenny Catalog", "numberOfItems": 0 }, "links": [ ..., { "rel": "next", "href": ".../opds?offset=50&limit=50" } ] }Zero publications,
numberOfItems: 0— and arel=nextlink. The library held 96 items the entire time.The two fields contradict each other:
numberOfItemssays the collection is empty, whilerel=nextsays there is another page to fetch. Both are emitted from the same response.Cause
opds_feedfalls back toLennyDataProvider.empty_catalog(...)when Open Library is unreachable (lenny/core/api.py, theRequestException/HTTPErrorhandler). That fallback is handed the real paging context —page["total"]fromItem.count()— but_lenny_empty_catalog(lenny/core/api.py:138-144) hardcodes:while
_lenny_catalog_linkscomputesrel=nextfrom thetotalit was given (offset + limit < page["total"]). So during a fallback the count is a literal0and the paging links describe a 96-item collection.Incidentally this is what let me measure the DB count from outside during the outage:
rel=nextwas present forlimit<=95and absent forlimit>=96, pinningItem.count()at exactly 96 while the feed insisted it was empty.Why this matters more than it looks
modified_since+rel=next+metadata.modifiedwere added in #200 specifically so Open Library's BookWorm harvester can harvest this feed incrementally (internetarchive/openlibrary#13241). That harvester is the primary consumer.A harvester that reads
numberOfItems: 0during any Open Library blip concludes the library has no books. Depending on how it reconciles, that is a plausible path to it pruning all 96 records from the registry — triggered not by a Lenny bug but by a transient upstream outage, and self-inflicted by a hardcoded zero.This is the same defect class as #203 —
numberOfItemsnot reconciled against what is actually emitted — but far more severe: not one book missing, but the entire catalogue, and reachable any time Open Library has a bad minute.Suggested fix
empty_catalogshould report the real total when it has one:pageis supplied, takenumberOfItemsfrompage["total"]rather than hardcoding0. An empty page of a non-empty collection is a legitimate state and should be representable.503is a better contract than a200describing an empty library — a harvester retries a 503 and does not prune on it.The second point is the important one. A
200that says "empty" is indistinguishable, to a consumer, from a library that genuinely has nothing — which is precisely why the outage was able to look like a data-loss event from the outside.Reproducing
Block egress to
openlibrary.orgfrom the API container (or pointOLat an unroutable host) and request/v1/api/opdswith any local items present. Observed on 0.2.15; the fallback path is unchanged in 0.2.16.