No matching sections.
Errors
Every failure - on every endpoint, from a malformed body to an upstream outage - is one shape:
an RFC 9457 application/problem+json document with a stable machine code and
structured remediation. There is nothing to parse out of a sentence.
Anatomy of a problem
{
"type": "https://api2.host-tracker.com/problems/invalid-interval",
"title": "The requested check interval is not allowed for this account.",
"status": 422,
"code": "invalid_interval",
"errors": [
{ "pointer": "/interval", "value": 300, "allowed": [60] }
]
}
- type always
- An absolute URL to this code's documentation page. Dereference it in a browser to read what the code means and how to fix it - it is a real page, not an identifier that happens to look like a URL.
- title always
- A short human summary of the type. Carries no data and may be reworded at any time.
- status always
- The HTTP status, repeated in the body so a logged document is self-contained.
- code always
- The stable machine identifier. This is the member to branch on. Codes are snake_case and never change meaning.
- detail
- Human detail about this particular occurrence, when there is any. For humans; may be reworded.
- instance
- The path the failure occurred on, when that is meaningful.
- errors
- The machine-actionable part - see below. Present whenever there is something specific to point at.
The errors[] array
Each entry locates a failure and carries the data needed to fix it. pointer is the
only member with a fixed meaning across codes; the rest are declared per code, which is exactly what
makes them useful.
- pointer
- An RFC 6901 JSON Pointer
into the request body -
/interval,/contacts/1/address- or/<name>for a query parameter. Absent when the problem is about the request as a whole rather than one value. - value
- What you sent, echoed back. Handy in logs; you do not have to correlate.
- allowed
- The permitted set. An empty
allowedmeans "none are accepted here" - a statement, not an omission. - reason
- A sub-classification when one code covers several distinct mistakes - e.g.
invalid_urlreports"required","malformed"or"scheme_not_allowed". - expected
- What the member should have been. When
reasonis"wrong_type"this is the JSON type the member must have -"string","number","integer","boolean","object","array"or"null"- and an array of those names when more than one is accepted, so["string", "null"]tells you the member is nullable. Note"integer"is stricter than"number": a value like3.5is refused where an integer is required. - didYouMean
- The nearest legal spelling, when there is an obvious one. Surface it; do not auto-apply it.
null, so "we do not know this" and "the value is empty" never look alike. Treat a
missing member as "no information", not as a bug.
Two more real examples. A misspelled or unsupported query parameter:
{
"type": "https://api2.host-tracker.com/problems/unknown-parameter",
"title": "The request carries a query parameter this endpoint does not define.",
"status": 422,
"code": "unknown_parameter",
"detail": "This endpoint accepts only the query parameters listed in allowed[].",
"instance": "/agent/pool",
"errors": [
{ "pointer": "/limit", "parameter": "limit", "allowed": [] }
]
}
And an unrecognised body member, which names every member that was accepted:
{
"code": "validation_failed",
"status": 422,
"errors": [
{
"pointer": "/url",
"reason": "unknown_member",
"allowed": ["activePeriod", "address", "alertDelay", "name", "type", …]
}
]
}
Unknown members are refused, never ignored. Silently dropping url on a contact would have created a contact with no address and no complaint.
Branch on code, not on prose
title and detail are written for people and may be
reworded, translated or clarified at any time - none of that is a breaking change. Matching on them is a bug
waiting for a copy edit.
r = session.post(url, json=body, headers=headers)
if r.status_code >= 400:
problem = r.json()
if problem["code"] == "invalid_interval":
allowed = problem["errors"][0]["allowed"] # fix and retry
body["interval"] = allowed[0]
elif problem["code"] == "quota_exceeded":
sleep(int(r.headers.get("Retry-After", 60))) # wait, then retry
else:
log.error("%s (%s) request=%s", problem["code"], problem["status"],
r.headers.get("x-request-id")) # log and surface
Log the code and the x-request-id on every failure. A
code you do not recognise should be surfaced, not swallowed: the vocabulary grows, and a new code is always
additive.
The live catalogue
type URL points into it.
The OpenAPI document goes further: each operation lists the exact codes it can answer, and each code has its own
schema with its remediation members typed - so a generated client gets
invalid_interval.errors[].allowed as a real field rather than prose to read. See the
interactive reference.
Three families of fix
Fifty codes, but only three things a client can usefully do. Routing on the family first and the code second is what keeps error handling small.
| Family | Retrying the same request… | What to do |
|---|---|---|
| 1. Fix the request | …fails identically, forever. | Read errors[], change the payload, resend. Never a blind retry loop. |
| 2. Fix the account state | …fails identically until something outside this request changes. | A token, a scope, a plan limit, an allow-list or a conflicting resource. Needs a human or a different call. |
| 3. Wait and retry | …will very likely succeed later. | Honour Retry-After; otherwise back off exponentially with jitter. |
1. Fix the request
Almost always 400, 405,
413, 415 or 422. The
errors[] entry tells you precisely what to change.
malformed_requestvalidation_failedunknown_parameterunknown_enum_valueunknown_expandunknown_fieldinvalid_cursorinvalid_limitinvalid_rangeinvalid_intervalinterval_below_type_floorinvalid_alert_delayinvalid_settingsinvalid_urlunknown_poolunknown_contact_refunknown_event_typeunsupported_report_channelcontact_type_not_creatablemonitor_type_discontinuedtype_immutablecredential_write_onlyfilter_requiredtoo_many_itemspayload_too_largeunsupported_media_typemethod_not_allowedinvalid_confirmation_codeidempotency_key_requiredinsufficient_agents
2. Fix the account state
401, 402, 403,
404 and 409. Resending will not help; something else
has to change first. A 409 in particular usually means the world moved - re-read,
then decide.
invalid_tokenmissing_scopeinsufficient_rightsip_not_allowedpackage_limitpackage_interval_conflictinsufficient_balanceurl_blacklistednot_foundduplicate_monitorduplicate_contactduplicate_resourcecontact_already_confirmedselection_mismatchidempotency_key_conflictjob_not_cancellablejob_not_resumable
3. Wait and retry
429, 500, 502,
503. This is the only family a retry loop belongs in.
quota_exceededrate_limitedservice_unavailableupstream_errorinternal_error
The two 429s are deliberately distinct, because the remedy differs:
| Code | Means | Remediation members |
|---|---|---|
quota_exceeded |
The account's quota for this scope is spent for the window. | limit, remaining, resetAt (Unix seconds) - plus
Retry-After and the RateLimit-* headers. |
rate_limited |
A short-window throttle on this specific endpoint. Unrelated to your quota. | limit, window, retryAfter - plus Retry-After. |
Waiting a second clears a rate_limited. Waiting until
resetAt - or upgrading - is what clears a
quota_exceeded. Collapsing the two into "429, sleep and hope" is why they were kept
apart. GET /account/quota reports your headroom per scope, on demand, without
spending any of it.
traceId and support
Every response carries an x-request-id header - success and failure alike. A
500 internal_error additionally puts the same value in the body:
{
"type": "https://api2.host-tracker.com/problems/internal-error",
"title": "Something went wrong on our side.",
"status": 500,
"code": "internal_error",
"errors": [ { "traceId": "req_65e641569696497884ce3a930a29489e" } ]
}
Quote it in a support request. It is the one string that finds your exact call in our logs, and it is why
logging x-request-id on every response - not only on failures - is worth the line
of code.
results[].error is a complete problem document, so one error handler covers both.
See Jobs & idempotency.