Skip to main content

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

a real 422
{
  "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 allowed means "none are accepted here" - a statement, not an omission.
reason
A sub-classification when one code covers several distinct mistakes - e.g. invalid_url reports "required", "malformed" or "scheme_not_allowed".
expected
What the member should have been. When reason is "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 like 3.5 is refused where an integer is required.
didYouMean
The nearest legal spelling, when there is an obvious one. Surface it; do not auto-apply it.
An absent member is a deliberate statement. Members are omitted rather than sent as 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:

422 unknown_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:

422 validation_failed
{
  "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.

Python
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

https://api2.host-tracker.com/problems is the complete, always-current list - one page per code, with its status, its remediation members and what to do about it. It is generated from the same registry the API answers from, so it cannot drift, and it is anonymous: a caller who just got a 401 can read what it means without being able to authenticate. Every problem's own 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.

FamilyRetrying 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:

CodeMeansRemediation 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:

500 internal_error
{
  "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.

Failures inside an asynchronous job use this same shape: results[].error is a complete problem document, so one error handler covers both. See Jobs & idempotency.