Errors and rate limits
Problem documents
Section titled “Problem documents”Errors are RFC 9457 problem documents (application/problem+json) with a
stable machine code. Match on code, not on title or detail.
{ "type": "https://opdns.io/problems/validation_failed", "title": "Validation failed", "status": 422, "code": "validation_failed", "errors": [{ "field": "settings.retention_days", "message": "must be 1 to 90" }], "request_id": "…"}Include request_id when you report a problem. The full list of codes is
the Problem schema in the reference.
| Status | Common codes |
|---|---|
| 400 | bad_request, body_too_large, bad_if_match, invalid_token, token_expired, token_used, invalid_cursor, bad_idempotency_key |
| 401 | unauthenticated, second_factor_required, invalid_recovery_code, node_revoked |
| 403 | insufficient_scope, session_required, csrf_rejected, reauth_required, mfa_enrolment_required, mfa_required |
| 404 | not_found (also for another organisation’s resources) |
| 409 | ip_conflict, idempotency_key_reused, idempotency_in_progress, profile_not_empty, profile_suspended, node_token_superseded |
| 412 | version_mismatch |
| 422 | validation_failed, unknown_list, too_many_rules, password_too_short, password_too_long, range_too_large, query_too_expensive |
| 429 | rate_limited, too_many_streams, too_many_reports, too_many_queries |
| 501 | not_implemented, shape_unsupported |
| 502, 503, 504 | node_error; node_offline, node_busy, relay_unavailable; node_timeout, query_timeout |
A new password (sign-up, change, reset, recovery) of fewer than 12 or more
than 128 Unicode code points is 422 password_too_short or
password_too_long, with the password field in errors; when other
fields are invalid too, the answer is validation_failed listing them
all. There is no other password rule.
409 profile_suspended answers a change to a profile an operator has
suspended: its settings, rules, lists, linked IPs, devices, deletion and
new nodes. Reads still work. The profile’s suspended field says so in
advance; see Suspended profiles.
Concurrency: ETag and If-Match
Section titled “Concurrency: ETag and If-Match”Every profile resource returns its version as a strong ETag ("3").
Send it back as If-Match on a write; if someone changed the profile in
between, the write fails with 412 version_mismatch and nothing changes.
Omit If-Match to write unconditionally. Every successful write bumps the
version and publishes the whole profile to the resolvers.
curl -X PATCH https://api.opdns.io/v1/profiles/abc123 \ -H "Authorization: Bearer $OPDNS_TOKEN" \ -H 'If-Match: "3"' -H 'Content-Type: application/json' \ -d '{"settings":{"retention_days":7}}'Pagination
Section titled “Pagination”Every list endpoint is cursor-paginated. Send limit (default 50, at most
200) and, for the next page, cursor set to the previous page’s
next_cursor; next_cursor is null on the last page. The order is
stable, so rows created while you page are never repeated or skipped
before the cursor. Cursors are opaque: a modified one is
400 invalid_cursor.
curl -H "Authorization: Bearer $OPDNS_TOKEN" \ 'https://api.opdns.io/v1/profiles?limit=100&cursor=…'Log queries page the same way, with their own limit (at most 1,000
rows).
Idempotency keys
Section titled “Idempotency keys”POSTs that create something (profiles, tokens, linked IPs, nodes,
exports, operator and source blocks, list reports) accept an Idempotency-Key header,
any unique string such as a UUID. Repeating the request with the same key
and the same body within 24 hours replays the first successful answer,
with Idempotent-Replayed: true, instead of creating a second one. So a
script can retry a request whose answer it never received.
| Situation | Answer |
|---|---|
| same key, same request, first one succeeded | the first answer again, Idempotent-Replayed: true |
| same key, first one still running | 409 idempotency_in_progress with Retry-After; retry with the same key |
| same key, different request | 409 idempotency_key_reused |
| first one failed | nothing is stored; the key can be used again |
Keys are per account.
Log query results
Section titled “Log query results”GET /v1/profiles/{id}/logs and /analytics/{shape} answer a
QueryResult: columns, rows, source (clickhouse, node or
none), next_cursor, and partial.
Log rows (tail and search) carry the stored columns (ts, source,
pop, node, profile_id, device_id, device_name, client_ip,
transport, qname, qtype, status, reason_list_id, reason_rule,
rcode, dnssec_validated, cache_hit, latency_us, answer_ips) and
four derived ones, computed the same way for cloud and node results,
whatever the node’s version:
| Column | Holds |
|---|---|
reason |
the list name when a list decided, else the rule or operator text as stored (denylist: *.example.com, rewrite: =nas.home, legal_order LO-17); empty when domain logging blanked a rule |
reason_code |
list, rule, operator:<code> (legal_order, abuse, csam), rewrite, or none for allowed and failed queries |
ede_code |
the Extended DNS Error the client received: 15 for operator blocks, 17 for every other block, 0 otherwise (a failed upstream’s EDE is not recorded) |
ede_text |
the EDE extra text: the reason of a block |
The top_reasons analytics shape groups queries by what decided them:
reason_code, list_id (for list only), reason and count, most
frequent first. Without a status filter it counts blocked and rewritten
queries. A self-hosted node older than this shape answers
501 shape_unsupported.
The destinations shape groups allowed queries by answer address:
ip, count, and since API 0.2.0 two more columns:
| Column | Holds |
|---|---|
country |
the ISO 3166-1 alpha-2 code of the address from the server’s GeoIP database; "" when unknown, when the address is not public, or when the deployment has no database |
owner |
the company whose address it is: google, apple, meta, amazon, microsoft, or the CDNs cloudflare, akamai, fastly; "" for any other |
The API adds both columns to answers that lack them (a node older than
this change) and fills an empty country from its own database, so a
relayed answer has countries even though nodes have none. When the
database’s licence asks for credit, geoip_attribution in GET /v1/meta
holds the notice to show next to the countries. How both are derived:
GeoIP and owner classification.
The top_owners shape counts queries by the company they reached:
owner and count, most first, ties by owner. A query’s owner is its
name’s (a curated list of domain suffixes: youtube.com is Google’s), or
else its first classified answer address’s; queries with neither are left
out. It has no default status filter. A node older than this shape answers
501 shape_unsupported.
The top_pops shape (API 0.5.0) counts queries by the PoP that answered
them: pop and count, most first, ties by pop. pop is the PoP’s
short id (ams), or node for queries a self-hosted node answered
itself; records without a PoP are left out. The default limit is 10, at
most 100, and there is no default status filter. A node older than this
shape answers 501 shape_unsupported.
curl -H "Authorization: Bearer $OPDNS_TOKEN" \ 'https://api.opdns.io/v1/profiles/abc123/analytics/top_pops?limit=20'To name and place the ids, GET /v1/pops
(no authentication) lists the deployment’s PoP catalogue, sorted by id:
id, name (the label to show, the city), city, country (ISO 3166-1
alpha-2), lat and lon (WGS 84 degrees) and status (active,
canary: serving and receiving changes first, or planned: not serving
yet). It changes only when the fleet does, so it may be cached for five
minutes (Cache-Control: public, max-age=300); a deployment without PoP
locations answers an empty array.
[ { "id": "ams", "name": "Amsterdam", "city": "Amsterdam", "country": "NL", "lat": 52.37, "lon": 4.9, "status": "active" }]partial is true when a node answered with fewer rows than asked
(partial_reason timeout, near its query deadline, or overload), or
when a self-hosted profile’s node is between link sessions
(node_reconnecting, no rows; a tail keeps its cursor). The rows present
are correct.
Log query limits
Section titled “Log query limits”Every log and analytics query passes the same guardrails before any store is read, whether the cloud’s log database or your self-hosted node answers it.
Range. Each shape covers at most a fixed time range:
| Shapes | Longest from..to |
|---|---|
search, top_devices, destinations, top_pops (read every row of the range) |
31 days |
counts_by_status, timeline, top_domains, top_blocked, top_reasons, top_owners (read hourly totals) |
400 days |
tail (the newest rows under limit) |
no limit |
A wider range is 422 range_too_large, with from named in errors. A
query without from is not refused: its range starts that long before
to (or now), so a bare search covers the last 31 days. The dashboard’s
widest preset, 30 days, fits every shape. Cloud logs are kept at most 90
days anyway.
{ "type": "https://opdns.io/problems/range_too_large", "status": 422, "code": "range_too_large", "detail": "search queries cover at most 31 days; narrow from and to", "errors": [{ "field": "from", "message": "at most 31 days before to" }]}Cost and concurrency. Queries draw on a budget per organisation: two
units per day of range for a query that reads raw rows (the 31-day shapes,
and any shape filtered by more than time), one unit per week of range for
one that reads hourly totals, one unit for a tail; the budget is refilled at 1,200 units a minute (about five 30-day Analytics
page loads). At most four queries of an organisation run at once; another
one waits up to five seconds for a slot. A query over budget, or still
without a slot after five seconds, is 429 too_many_queries with
Retry-After (seconds, also retry_after in the body): wait that long
and retry.
Expensive queries. A cloud query that would read more rows (500
million) or memory (2 GiB) than one query may is stopped and answered
422 query_too_expensive: narrow the range or add filters, retrying the
same query will not help.
These are the free plan’s defaults. They are counted per API server, not across the whole service. Values for paid plans are planned.
Live log stream
Section titled “Live log stream”GET /v1/profiles/{id}/logs/stream (scope logs:read) is the live tail
as server-sent events, with the filters of a tail log query:
event: rowswithid: <cursor>and, asdata, a query result holding the new rows. The first event carries the latest rows (up tolimit) unless you resume. Events are sent only when there are rows.event: errorwith a problem asdata: a failure while streaming. The stream goes on after a transient one (node_offline,node_timeout, a 5xx) and ends after any other.event: partialwith{"partial": true, "partial_reason": "node_reconnecting"}: a poll came back partial with no rows (the node is reconnecting). Sent once until a complete poll;rowsevents carry their ownpartialflag.- a
: heartbeatcomment every 15 seconds. Each heartbeat re-checks your credential and the profile: a revoked session or token, or a deleted profile, ends the stream.
Reconnect with Last-Event-ID (browsers’ EventSource does it by itself)
or ?cursor= set to the last event id to resume without gaps or repeats.
The server checks the log destination every second and ends a stream after
an hour; reconnect. An account may hold 4 streams at once per API server;
beyond that the answer is 429 too_many_streams, and polling
GET /v1/profiles/{id}/logs?mode=tail with cursor is the fallback the
dashboard uses.
curl -N -H "Authorization: Bearer $OPDNS_TOKEN" \ 'https://api.opdns.io/v1/profiles/abc123/logs/stream?status=blocked'Logs from a node
Section titled “Logs from a node”Log and analytics queries are answered from the profile’s log destination.
For a self-hosted destination they are relayed to the node, which can be
offline (503 node_offline with Retry-After), busy (503 node_busy, at
most 4 queries in flight per node), slow (504 node_timeout, 10 seconds) or
too old for a query shape (501 shape_unsupported).
List reports
Section titled “List reports”POST /v1/profiles/{id}/list-reports (scope profiles:write) reports a
domain that list list_id blocks but should not (kind
false_positive) or misses (false_negative), with an optional note of
up to 500 characters. The answer is 202 with the report’s id. The
domain is stored in lower case without a trailing dot, with the list’s
catalogue version at the time. An account may send 20 reports in 24 hours;
the 21st is 429 too_many_reports with Retry-After. GET on the same
path (scope profiles:read) lists the profile’s reports, newest first,
with their status (open, accepted, rejected), the operators’
resolution_note and triage_due_at, the maintainers’ target for an
answer: two business days (Monday to Friday, UTC) after the report. When
the maintainers accept or reject a report, the reporter gets an email with
the outcome and their note. Accepting a report changes no list by itself;
see List reports.
Rate limits
Section titled “Rate limits”Unauthenticated routes (sign-up, sign-in, the catalogue, enrolment, DDNS)
are limited per client IP and answer 429 rate_limited with Retry-After
(seconds, repeated as retry_after in the body). Per-token and
per-organisation limits with RateLimit-* headers are planned;
until then, keep scripts polite.
Dashboard error reports
Section titled “Dashboard error reports”POST /v1/client-errors (a session, or a token with account:read)
stores a scrubbed error report from the dashboard: message, stack, route,
version and user agent, at most 32 KiB, 10 reports a minute per account.
The first dashboard builds’ field names release, route and at are
deprecated.
Reports are kept 30 days and deleted with the account; operators list them
with GET /v1/admin/client-errors. The dashboard’s own reporting can be
turned off per browser (Error reports).