Jikan 504s when its cache misses โ€” mock MyAnimeList shapes for dev & CI, keep Jikan for prod

Jikan is the API behind nearly every "build an anime search app" tutorial โ€” free, no key, the whole MyAnimeList catalogue. It's also, by its own description, an unofficial API: a community-run cache that scrapes MAL on your behalf. That architecture has a failure mode you will eventually meet. While writing this guide (September 2026), the classic tutorial call failed:

$ curl "https://api.jikan.moe/v4/anime?q=naruto"
HTTP/2 504
{"status":504,"type":"BadResponseException","message":"Jikan failed to connect
 to MyAnimeList. MyAnimeList may be down\/unavailable or refuses to connect",
 "error":null}

At the same moment, /v4/anime/1 answered 200 in under a second โ€” because it was in Jikan's cache. That's the trap in one screenshot: cache hit = fast 200, cache miss = whatever mood MyAnimeList is in right now. Your search box works all afternoon, then 504s during the demo, because "Naruto season 4 page 3" fell out of cache. On top of that, Jikan's docs set a rate limit of 60 requests/minute and 3 requests/second โ€” easy to trip from a hot-reloading dev server or a parallel test suite. And the official MyAnimeList API isn't a quick fallback: it wants an OAuth2 app registration and token flow before your first request.

None of this is a knock on Jikan โ€” it's a genuinely great community project, and for production anime data it's the right choice. But your dev loop and CI shouldn't depend on MAL's uptime or share a 60/min budget. They need the exact response shapes, deterministically. That's a mock.

1. Capture the real top-25 once, mock forever

Pull Jikan's top-anime roster one time and host it as your own API (records keep every field verbatim โ€” mal_id, score, rank, image URLs, the nested titles array, all of it):

curl -s "https://api.jikan.moe/v4/top/anime" | jq '{anime: .data}' > db.json

curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=jikan-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 โ€” nested objects like images and aired are kept verbatim in the records but skipped from the flat field schema. Your copy answers instantly, from anywhere, with a query toolkit Jikan doesn't have:

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/anime?title_like=frieren&select=title,score,rank"
[{"id":1,"title":"Sousou no Frieren","score":9.26,"rank":1}]

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/anime?score_gte=8.9&select=title,score"
[{"title":"Sousou no Frieren","score":9.26},
 {"title":"Fullmetal Alchemist: Brotherhood","score":9.11}, โ€ฆ]

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/anime?sortBy=rank&order=asc&limit=3&select=mal_id,title,rank"

Free bonuses: /m/YOUR_ID/types.ts generates TypeScript interfaces from the imported schema (?format=zod for Zod), and /graphql serves the same records with typed queries.

2. Reproduce Jikan's pagination envelope

Jikan wraps every list in {"pagination":{โ€ฆ},"data":[โ€ฆ]}, and your frontend almost certainly reads resp.data and resp.pagination.has_next_page. Make the mock answer in that exact shape โ€” set it once as the project default with a response envelope template:

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":{"pagination":{"has_next_page":"$hasMore","current_page":"$page","items":{"count":"$count","total":"$total","per_page":"$limit"}},"data":"$data"}}'

Now every list response is Jikan-shaped (we verified this output on a live project while writing the guide):

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/anime?limit=2&page=1"
{"pagination":{"has_next_page":true,"current_page":1,
  "items":{"count":2,"total":25,"per_page":2}},
 "data":[{"mal_id":52991,"title":"Sousou no Frieren",โ€ฆ},{โ€ฆ}]}

Your parsing code runs unchanged. (Jikan's last_visible_page isn't in the template vocabulary โ€” if your code depends on it, compute it from total and per_page, or pin a custom route.)

3. Byte-exact drop-in paths

If your code hardcodes Jikan's /v4/โ€ฆ paths, reproduce them as custom routes. Capture the real detail endpoint and serve it back byte-for-byte โ€” we verified the served response parse-equal to the live API's while writing this:

curl -s "https://api.jikan.moe/v4/anime/1" > anime1.json
jq '{path:"/v4/anime/1", body:.}' anime1.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 @-

$ curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/v4/anime/1"
{"data":{"mal_id":1,"url":"https://myanimelist.net/anime/1/Cowboy_Bebop",โ€ฆ}}

Route bodies are static โ€” good for detail pages and fixtures. For anything you want to query, use the /anime resource from ยง1.

4. Order the 504 (and the 429) on demand

The best reason to mock a fragile upstream: you can finally test the failure. Your error handling has almost certainly never seen Jikan's real 504 body in CI. Serve it verbatim โ€” this is the exact payload we captured up top (note: the body must be a single line; a raw newline inside a JSON string is invalid JSON):

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"path":"/drills/504","status":504,"body":{"status":504,"type":"BadResponseException","message":"Jikan failed to connect to MyAnimeList. MyAnimeList may be down/unavailable or refuses to connect","error":null}}'

Now expect(err.type).toBe("BadResponseException") runs against the same bytes production will see. For retry logic, ?mock_seq=504,504,200 fails exactly twice then succeeds โ€” assert your third attempt renders. And ?mock_ratelimit=3 is Jikan's 60/min budget in miniature: three 200s with decrementing x-ratelimit-remaining, then a 429 with a real Retry-After to point your backoff at. More recipes: rate-limit simulation, loading & error states.

5. Honest comparison

JikanMAL official APIYour mock
Real, current MAL dataโœ” (cached 24h-ish)โœ”โœ˜ frozen at capture โ€” by design
Signup / keynoneOAuth2 app registrationnone
Catalogue30,000+ anime, manga, characters, seasonswhatever you import
Uptime couplingcache miss โ‡’ depends on MAL right nowMAL itselfindependent
Rate limit60/min, 3/secper-app10,000/day per project
Deterministic for testsโœ˜ rankings move, cache variesโœ˜โœ”
Can simulate 504 / 429 / latencyโœ˜ (only by accident)โœ˜โœ” mock_seq/mock_ratelimit/mock_delay
Right forproduction readsproduction writes (lists)dev, CI, demos, workshops

The pattern that uses each where it wins: base URL in an env var โ€” Jikan in production, your /m/YOUR_ID in .env.test. Your dev loop stops depending on MyAnimeList's mood. Related: Open Library API, PokeAPI, Rick and Morty API, mock any third-party API.

โšก Skip the terminal: paste your captured JSON in the dashboard importer (CSV, OpenAPI, db.json, Postman, and HAR work too) โ€” or create a seeded sandbox in one click. No signup.