On this page
Errors and limits
Every failure is an RFC 7807 application/problem+json response. Images and errors never mix: if you asked for a PNG and the status is not 200 (or 304), the body is JSON.
The shape#
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://wherebox.dev/docs/errors#invalid-spec",
"title": "Invalid map spec",
"status": 400,
"detail": "The request does not match the map spec",
"issues": [{ "path": "fade", "message": "…" }]
}type is a stable code, title a short human label, status repeats the HTTP status, and detail says what happened. Some errors add fields: issues on 400, suggestions on 404, candidates on 409. The trailing part of type (after #) is the code, and every code has an entry in the error reference, which is generated from the same table the server uses. The part before # is the address of that entry in the published docs; a self-hosted server can point it at its own copy with WHEREBOX_DOCS_URL, so match on the code, not on the whole URL.
Branch on type, not on text#
title and detail are for people and may be reworded. type and status are the contract. A client usually needs three branches:
const res = await fetch(url);
if (res.ok) return res; // 200 image, or 304 with If-None-Match
const problem = await res.json();
const code = problem.type.split('#')[1]; // "ambiguous", "busy", "invalid-spec", …
switch (code) {
case 'ambiguous': // 409: let the user pick problem.candidates[i].id, retry with area=<id>
case 'not-found': // 404: offer problem.suggestions
case 'invalid-spec': // 400: a bug in your request; log problem.issues
case 'busy': // 503: retry, see below
default: // upstream-failed/-timeout (502/504): retry later or fall back
}Which code means what, in one line each:
| Status | Code | What to do |
|---|---|---|
| 400 | invalid-spec |
Fix the request. issues[] lists each bad parameter by path; unknown parameters come with a "did you mean" hint. Not retryable. |
| 400 | bad-request |
Something else is unacceptable as sent: a rejected icon or font, an unsupported extension, routing not configured. Not retryable. |
| 404 | not-found |
No such area or place. Use suggestions, or look it up with /v1/areas/search. |
| 409 | ambiguous |
Several areas match; see Locator maps. |
| 422 | no-route |
The routing engine found no route for that profile. |
| 503 | busy |
The render queue is full. Retry. |
| 502 | upstream-failed |
A basemap, tile or routing server failed. Retry later. |
| 504 | upstream-timeout |
A routing or asset server was too slow. Retry later. |
Ambiguous names in practice#
A name that matches several areas is the one error you should expect in normal use. This request is parsed in the tests, but it is 409 when sent:
/v1/map.pngGET /v1/map.png?area=Nová+Ves&w=400&h=300The response carries a candidates array; each entry has an id, an adminLevel and a context. Show the contexts to a person, then repeat the request with area=<id>. Or narrow it up front with level and country.
Invalid parameters#
Unknown parameters are rejected rather than ignored, so a typo cannot silently produce a different map. GET /v1/map.png?aera=Czechia returns 400 invalid-spec with an issue like unknown parameter "aera" (did you mean "area"?). Out-of-range numbers and malformed colors are reported the same way, each with its path.
Retries on 503#
When more renders are requested than the server can run and queue, it answers 503 with busy and a Retry-After header (in seconds). Retry after that long, with a little jitter, and give up after a few attempts. Retry 502 and 504 the same way. Do not retry 400, 404, 409 or 422: the answer will not change.
Identical concurrent requests share a single render, and a repeated request is served from the cache without taking a render slot, so retry storms on one URL are cheap.
async function fetchMap(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const res = await fetch(url);
if (res.status !== 503 && res.status !== 502 && res.status !== 504) return res;
const wait = Number(res.headers.get('retry-after') ?? 2) * 1000 * (i + 1);
await new Promise((r) => setTimeout(r, wait + Math.random() * 250));
}
throw new Error('map service unavailable');
}The queue size and concurrency are operator settings; see the configuration reference.
Caching and ETags#
Maps are content-addressed: the cache key is a hash of the whole request (after defaults are applied) plus the renderer and data versions. The same request always gives the same image until the server's data or renderer changes.
- Image responses carry
ETag(the hash),Cache-Control: public, max-age=86400andX-Cache: HIT|MISS. SendIf-None-Matchwith the ETag to get an empty304when nothing changed. - GET responses can sit behind a CDN or the browser cache as they are. Because the key covers every parameter, a changed parameter is a new URL and a new image; there is nothing to purge.
POST /v1/map?response=jsonreturns{url, key, meta}. Theurl(/v1/images/{key}) is immutable (max-age=31536000, immutable), so store it or put it in a page. If the cached image has expired, that URL returns404 not-found; re-send the POST to render it again.- Two headers describe the result:
X-Image-WidthandX-Image-Height(the real pixel size, which can exceedw × scalewith neighborextend) andX-Render-Mson a fresh render.
Parameters beginning with an underscore (&_v=2) are ignored by the parser. Use one if you need a different URL for the same map, for example to get past a stale browser or CDN entry; the server still returns the same cached image.