Every DietlyAPI error explained, with handling recipes
DietlyAPI returns five error statuses in normal operation: 401, 403, 404,
422 and 429. Four of them carry detail as a string, and 422 carries
detail as a list of objects. That single shape change is the most common cause
of a crash in an otherwise working integration, because a handler written as
err["detail"].lower() works perfectly until the first validation error. This page lists
every status the API produces, the exact body it sends, what causes it, and the handling that belongs
with it. Every example below was run against the live API.
| Status | Body | Retry? |
|---|---|---|
401 | {"detail": "Missing or invalid API key"} | No, fix the request |
403 | {"detail": "This API key is read-only. ..."} | No, use a different key |
404 | {"detail": "Barcode not found"} | No, treat as empty state |
422 | {"detail": [ {...} ]}, a list | No, fix the parameters |
429 | {"detail": "Rate limit exceeded. Retry in N seconds."} | Yes, after Retry-After |
401: missing or invalid API key
The API expects a bearer token: Authorization: Bearer YOUR_KEY. You get a 401 when the
header is absent, when the scheme is not Bearer, or when the key does not exist. The body
is deliberately identical in all three cases so the endpoint cannot be used to probe which keys are
real.
$ curl -s https://api.getdietly.com/nope
{"detail":"Missing or invalid API key"}
There is one non-obvious cause worth knowing, and it is the reason that example uses a nonsense
path. Authentication runs before routing, so a URL that does not exist returns 401 rather than
404. If you are certain your key is valid and you are still getting 401, check the path before
you check the key. The classic version of this mistake is inserting a version prefix:
/v1/search returns 401 because there is no /v1 namespace, while
/search works. The public read endpoints (/search, /barcode/{code},
/food/{id}) need no key at all, so a 401 from one of those is nearly always a typo in the
path.
403: the key exists but is not allowed to do this
A 403 means authentication succeeded and authorisation failed. There are two forms, and the message tells you which.
{"detail":"This API key is read-only. Use a full-access key for this endpoint."}
{"detail":"Use your account key to manage dashboard settings."}
The first appears when a key created with read scope is used for anything that is not a GET. Read
scope is the right default for anything you deploy to a server that only looks data up, and the
restriction is the point rather than a limitation. The second appears when a named access key is used
against /auth/ or /billing/ routes; account management uses the account key,
not the per-service keys. Neither is retryable. Both are configuration mistakes that surface once, at
integration time, and never again.
404: no such record
Direct lookups return 404 when nothing matches: GET /food/{id} for an unknown id, and
GET /barcode/{code} for a barcode with no product. This is an ordinary outcome in a
barcode scanner, not a failure, and it should never surface to a user as an error dialog.
$ curl -s https://api.getdietly.com/barcode/5099999999999
{"detail":"Barcode not found"}
Note the asymmetry with search: GET /search with no matches returns 200
and an empty array [], not a 404. Search answers "what matched", which can legitimately be
nothing; a direct lookup answers "give me this record", which either exists or does not. Handle both,
and treat both as the same empty state in your UI.
422: your parameters did not validate
This is the one that breaks parsers. On 422, detail is a list of validation
objects, each with type, loc, msg and input. Every
other error sends detail as a plain string.
$ curl -s "https://api.getdietly.com/search?q=a"
{"detail":[{"type":"string_too_short","loc":["query","q"],
"msg":"String should have at least 2 characters",
"input":"a","ctx":{"min_length":2}}]}
$ curl -s "https://api.getdietly.com/search?q=oats&limit=999"
{"detail":[{"type":"less_than_equal","loc":["query","limit"],
"msg":"Input should be less than or equal to 50","input":"999"}]}
$ curl -s https://api.getdietly.com/food/abc
{"detail":[{"type":"int_parsing","loc":["path","food_id"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}
The three rules worth encoding in your client: q is required and needs at least two
characters, limit must be between 1 and 50, and a food id must be an integer. Validate
those before you send, because a 422 still costs you a round trip. In particular, do not pass a raw
search box through on every keystroke: a single-character query is guaranteed to 422, so debounce and
require two characters locally.
def describe_error(response):
# Return a readable message for any DietlyAPI error body.
try:
detail = response.json().get("detail")
except ValueError:
return f"HTTP {response.status_code}"
if isinstance(detail, list): # 422 validation
return "; ".join(
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
for item in detail
)
return str(detail) # 401, 403, 404, 429
429: rate limited
The limit is per minute. Anonymous and Starter traffic is capped at 30 requests per minute per IP,
with an additional hourly cap that only a scraper would reach; Pro is 500 per minute and Scale 3,000
per minute, both counted per account rather than per IP. When you exceed it you get a 429 with a
Retry-After header in seconds.
{"detail":"Rate limit exceeded. Retry in 12 seconds."}
Retry-After: 12
Requests made with a key also carry the full trio on both success and 429:
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Anonymous traffic gets Retry-After only. If you are building a dashboard or a bulk sync,
read X-RateLimit-Remaining on every response and slow down before you hit zero rather than
after. That is the difference between a sync that finishes and one that spends its life in backoff.
A retry policy that fits these semantics
Only two of the five statuses are retryable, and one of them tells you exactly how long to wait. Everything else is a bug in your request that no amount of retrying will fix, which makes the policy short.
import time
import requests
RETRYABLE = {429, 500, 502, 503, 504}
def request_with_retry(session, method, url, tries=4, **kwargs):
delay = 1.0
for attempt in range(tries):
r = session.request(method, url, timeout=10, **kwargs)
if r.status_code not in RETRYABLE:
return r
if r.status_code == 429:
wait = float(r.headers.get("Retry-After", delay))
else:
wait = delay
delay *= 2 # exponential backoff for 5xx only
if attempt == tries - 1:
return r
time.sleep(wait)
return r
Three details make this behave well in production. Honour Retry-After literally instead
of applying your own backoff on top of it, because the server already knows when the window resets.
Back off exponentially only for 5xx, where you have no information. And never retry 401, 403, 404 or
422: a retry loop on a 422 is an infinite loop that also burns your rate limit.
Deciding what is an error in your own domain
The status code tells you what happened at the transport level. Your application still has to decide
what it means. A useful split is three buckets. Empty results covers 404 on a lookup
and [] from a search, and belongs in the UI as "no match found", with an option to submit
the product. Caller bugs covers 401, 403 and 422, and belongs in your logs and your
tests, never in front of a user. Transient covers 429 and 5xx, and belongs in the retry
path with a visible spinner rather than an error. Fold the first bucket into normal flow and most of
the perceived unreliability of any API disappears.
One more thing worth handling explicitly: nutrients can be null on a perfectly
successful 200. A label that did not declare fibre gives you "fiber_g": null. Treat that
as unknown, never as zero, because rendering a confident 0 g for an undeclared nutrient is a data
error your users will notice and the API will get blamed for.
Before you file a bug
Check the status page first: if the API is degraded, retries are the answer rather than code
changes. Then reproduce with curl and no key against a public read endpoint, because that
removes authentication, your HTTP client and your framework from the picture in one step. If a plain
curl works and your client does not, the difference is in your request, and the response body will
usually name the parameter.
Common questions
Why am I getting a 401 when my API key is valid?
Because authentication runs before routing, so a path that does not exist returns 401 rather than 404. The most common cause is adding a version prefix: /v1/search returns 401 while /search works. The public read endpoints need no key at all.
Why does my error handler crash on 422?
On 422 the detail field is a list of validation objects, while every other DietlyAPI error sends detail as a plain string. Code written as detail.lower() works until the first validation error and then raises.
What are the DietlyAPI rate limits?
Anonymous and Starter traffic gets 30 requests per minute per IP plus an hourly anti-scraping cap. Pro is 500 per minute and Scale 3,000 per minute, both counted per account. Exceeding the limit returns 429 with a Retry-After header in seconds.
Does an empty search return 404?
No. GET /search with no matches returns 200 and an empty array. Only direct lookups, GET /food/{id} and GET /barcode/{code}, return 404 when the record does not exist.