Open Library (openlibrary.org) is an Internet Archive project: an open, editable catalog of tens of millions of books, with a free keyless API behind a huge share of "build a book search app" tutorials. It deserves the praise it gets. It is also, operationally, a donation-funded nonprofit β and it behaves like one. While writing this guide (September 2026), roughly 1 in 6 of our calls hung for ~15 seconds and then dropped at the TLS handshake β no HTTP status, no error body, just:
$ curl "https://openlibrary.org/search.json?q=foundation+asimov&limit=5"
curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to openlibrary.org:443
In a browser that's TypeError: Failed to fetch β the code path your catch block sees least often and your tests exercise never. Minutes later the very same URLs answered in 0.4s. When healthy, searches took 2β5 seconds in our timed runs. And the official docs ask for restraint: 1 request/second for unidentified clients (3/sec if your User-Agent includes contact info), and no bulk downloading β that's what their data dumps are for.
None of this is a complaint. For production book lookups, Open Library is the right choice β cache responses, identify yourself, and consider supporting the Archive. But a hot-reloading dev server that re-fires the search on every keystroke, or a CI matrix hitting it from six parallel workers, is fragile for you and rude to a nonprofit. Point those at a mock with the exact shapes.
Live-verified on real works, because both bite constantly:
description is two different types. On /works/OL1168083W.json (Nineteen Eighty-Four) it's a plain string. On /works/OL27448W.json (The Lord of the Rings) it's {"type":"/type/text","value":"β¦"}. Render code that assumes one shape prints [object Object] or throws on .value β the classic Open Library bug. (Author bio has the same split.)/works/β¦json gives authors:[{"author":{"key":"/authors/OL26320A"}}] β an N+1 fetch per author for the name. Search docs do carry author_name, which is why apps mix both endpoints and inherit both failure modes.Pull one real search and host the docs as your own API β records keep every field verbatim (key, cover_i, the author_name array, first_publish_year, all of it):
curl -s "https://openlibrary.org/search.json?q=lord+of+the+rings&limit=20" \
| jq '{books: .docs}' > db.json
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=openlibrary-mock" \
-H 'content-type: application/json' --data-binary @db.json
No signup, no key. The response has your project id and adminKey, plus honest warnings β array fields like author_name are kept verbatim in the records but skipped from the flat field schema. Your copy answers instantly, deterministically, with a query toolkit Open Library's search syntax doesn't map to:
$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/books?title_like=hobbit&select=title,first_publish_year"
[{"id":5,"title":"The Hobbit & The Lord of the Rings [collection/set]","first_publish_year":1979},
{"id":15,"title":"The Hobbit","first_publish_year":1937}]
$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/books?first_publish_year_gte=2001&select=title,first_publish_year"
$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/books?sortBy=first_publish_year&order=asc&limit=3"
?q= searches whole records including the nested arrays β ?q=tolkien matches on author_name even though it's not in the flat schema. Writes persist: POST /books a record and it's there on the next GET. Free bonuses: /m/YOUR_ID/types.ts generates TypeScript interfaces (?format=zod for Zod), and /graphql serves the same records with typed queries.
Your frontend reads resp.docs and resp.numFound. Make the mock answer in Open Library's exact search wrapper β set it once as the project default with a response envelope:
curl -X PUT "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/settings" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
-d '{"envelope":{"numFound":"$total","start":0,"numFoundExact":true,"num_found":"$total","q":"lord of the rings","documentation_url":"https://openlibrary.org/dev/docs/api/search","docs":"$data"}}'
Verified output from a live project while writing this:
$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/books?limit=2&select=title"
{"numFound":20,"start":0,"numFoundExact":true,"num_found":20,
"q":"lord of the rings","documentation_url":"β¦",
"docs":[{"id":1,"title":"The Lord of the Rings"},{"id":2,"title":"The Fellowship of the Ring"}]}
Both spellings (numFound and num_found) included, because the real API sends both. Need a bare array somewhere? ?mock_envelope=none disables it per request.
If your code hardcodes /works/β¦json paths, reproduce them as custom routes. Capture one work of each description shape and serve them back byte-for-byte (we verified both served responses parse-equal to the live API's, and that the envelope from Β§2 does not touch custom routes):
curl -s "https://openlibrary.org/works/OL27448W.json" > lotr.json # description is {type,value}
curl -s "https://openlibrary.org/works/OL1168083W.json" > b1984.json # description is a plain string
jq '{path:"/works/OL27448W.json", body:.}' lotr.json \
| curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-
jq '{path:"/works/OL1168083W.json", body:.}' b1984.json \
| curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
-H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-
Now one test renders /m/YOUR_ID/works/OL27448W.json and the other /m/YOUR_ID/works/OL1168083W.json β your description-handling code is finally exercised against both real shapes on every CI run, not whenever the right book happens to come up.
The failure modes you met above are exactly what you can now test deterministically:
?mock_delay=3000 is an honest Open Library search. Do your skeletons hold up for 3 seconds without layout shift?AbortSignal.timeout(2000)) and point it at ?mock_delay=4000: fetch throws with no response object, same as the real drop. We verified curl exits 28 (timed out) on exactly this setup.?mock_seq=503,503,200 fails exactly twice then succeeds β assert your third attempt renders.?mock_ratelimit=3 is the 1β3 req/sec etiquette in miniature β three 200s with decrementing x-ratelimit-remaining, then a 429 with a real Retry-After to point your backoff at. More: rate-limit simulation, loading & error states.| Open Library | Your mock | |
|---|---|---|
| Real, current catalog data | β tens of millions of records, editable, covers API | β frozen at capture β by design |
| Signup / key | none | none |
| Latency | 2β5s searches when healthy (our timed runs) | edge-served, instant |
| Reliability | ~1 in 6 calls dropped in our session; fine an hour later | independent of openlibrary.org |
| Rate limit | 1/sec (3/sec identified), per their docs | 10,000/day per project |
| Deterministic for tests | β catalog is editable, results shift | β |
| Can simulate slowness / drops / 429 | β (only by accident) | β mock_delay/mock_seq/mock_ratelimit |
| Right for | production reads (cached, identified, kindly) | dev, CI, demos, workshops |
The pattern that's fair to everyone: base URL in an env var β Open Library in production behind a cache, your /m/YOUR_ID in .env.test and the dev server. Their servers stop seeing your hot reloads; your CI stops seeing their bad minutes. Current status of the real API: /status/openlibrary. Related: Free Dictionary API, Jikan (MyAnimeList), REST Countries, mock any third-party API.