Skip to content

API Reference

Error Handling

The full Kubeadapt /v1 error code catalog, error families, retry rules, the details payload shape, and the universal error envelope.

Last updated


Every /v1 error uses the same {data: null, meta, error} envelope. The error block carries a stable code from a closed catalog of 23 values plus a human message and optional details. Codes are grouped into families (auth, request shape, validation, not-found, pagination, rate, server, upstream) with consistent retry semantics.

The error envelope

Failed requests use the same envelope as successful ones, with data: null and a populated error block. The HTTP status code is in the response status line, the URL is the one you sent, and the request_id lives at meta.request_id — none of these are duplicated inside the body.

json
1{
2  "data": null,
3  "meta": {
4    "request_id": "0196f1c2-5e22-75a4-8dfe-bed49758b73c",
5    "applied_at": "2026-05-21T10:30:00Z"
6  },
7  "error": {
8    "code": "INVALID_COST_MODE",
9    "message": "cost_mode is not supported on this endpoint",
10    "details": [
11      { "field": "cost_mode", "reason": "not_supported_on_this_endpoint" }
12    ]
13  }
14}
  • error.code is the stable identifier. Branch on the code, never on the message string or the HTTP status alone.
  • error.details is an optional array. Some errors carry per-field reasons; many do not.

For the envelope contract (success and error shape, omitted vs null error), see API Overview. When reporting an issue to support@kubeadapt.io, include meta.request_id.

Error code catalog

This is the closed set. The API never returns a code outside this list; clients can safely raise an exception on any unknown code.

HTTPerror.codeFamilyRetryable?
400 / 404 / 405BAD_REQUESTRequest shape. Also returned with 404 for a path that does not exist and with 405 for the wrong HTTP method.No
400INVALID_CURSORPaginationNo
400INVALID_CLUSTER_IDValidationNo
401UNAUTHORIZEDAuthNo
403FORBIDDENAuthNo
403CLUSTER_ACCESS_DENIEDAuth (cluster ACL)No
404CLUSTER_NOT_FOUNDNotFoundNo
404NAMESPACE_NOT_FOUNDNotFoundNo
404WORKLOAD_NOT_FOUNDNotFoundNo
404NODE_NOT_FOUNDNotFoundNo
404NODE_GROUP_NOT_FOUNDNotFoundNo
404RECOMMENDATION_NOT_FOUNDNotFoundNo
404TEAM_NOT_FOUNDNotFoundNo
404DEPARTMENT_NOT_FOUNDNotFoundNo
410CURSOR_EXPIREDPaginationNo (restart)
422VALIDATION_ERRORValidationNo
422INVALID_COST_MODEValidationNo
429RATE_LIMITEDRate limitYes (with backoff)
500INTERNAL_ERRORServerYes (with backoff)
502UPSTREAM_UNAVAILABLEUpstream (Cost Explorer, FOCUS export)Yes (with backoff)
503RATE_LIMIT_UNAVAILABLERate-limit enforcement temporarily unavailableYes (with backoff)
503SERVICE_UNAVAILABLEReserved; not returned todayYes (with backoff)
504UPSTREAM_TIMEOUTTimeout (Cost Explorer, Cost Trends, FOCUS export)Yes (with backoff)

Six codes are retryable. The rest are non-transient programming or data errors; retrying them wastes quota.

Family breakdown

Auth (401, 403)

Three distinct codes, three distinct meanings. Branch on the code, not on the HTTP status.

CodeWhat it meansWhat to do
UNAUTHORIZEDThe Authorization header is missing, malformed, or carries an invalid/expired token.Recheck the Bearer header. Rotate the key if it was revoked.
FORBIDDENThe token is valid, but lacks the scope the endpoint requires (for example clusters:read), or has no scopes at all.Mint a new key with the right scopes. details[0].required lists the scopes the endpoint accepts; details[0].granted lists the key's scopes.
CLUSTER_ACCESS_DENIEDThe token has the right scope at the org level, but is not allowed on the cluster you queried (the one in the request path, cluster_id filter or Cost Explorer cluster_ids).Use a key whose allow-list covers the cluster.

A FORBIDDEN response lists the scopes the endpoint accepts and the scopes the key holds:

json
1{
2  "data": null,
3  "meta": {
4    "request_id": "0196f1c2-7a3e-7b5d-9c41-2e8f6a1d3b71",
5    "applied_at": "2026-05-21T10:30:00Z"
6  },
7  "error": {
8    "code": "FORBIDDEN",
9    "message": "insufficient scope",
10    "details": [
11      { "required": ["clusters:read"], "granted": ["organization:read"] }
12    ]
13  }
14}

A key with no scopes at all gets "message": "API key has no scopes" and "details": [{ "reason": "no_scopes_on_key" }].

A CLUSTER_ACCESS_DENIED response for a cluster outside the allow-list carries no details. The cluster is the one in your request:

json
1{
2  "data": null,
3  "meta": {
4    "request_id": "0196f1c2-7a3e-7b5d-9c41-2e8f6a1d3b72",
5    "applied_at": "2026-05-21T10:30:00Z"
6  },
7  "error": {
8    "code": "CLUSTER_ACCESS_DENIED",
9    "message": "access to this cluster is not allowed"
10  }
11}

Request shape (400)

Returned when the inputs are malformed before any business logic runs: UUIDs that aren't UUIDs, cursors that fail to decode, limit=abc, a body that fails to parse.

CodeWhen
BAD_REQUESTGeneric malformed input: limit out of range, an unparseable number, a body that fails to parse.
INVALID_CURSORThe ?cursor= value is not base64url-decodable or its decoded JSON is malformed. Restart pagination.
INVALID_CLUSTER_IDA path or query cluster ID is not a valid UUID. Different from CLUSTER_NOT_FOUND (which means the ID parsed but the cluster doesn't exist).

Validation (422)

Returned when the input parses but is semantically wrong. Wrong enum value, conflicting flags, a string where a number was needed and parseable.

CodeWhen
VALIDATION_ERRORGeneral semantic error. details lists the field, the reason, and the allowed set when applicable.
INVALID_COST_MODETwo cases. First: ?cost_mode= carries a value that is not one of fully_loaded / workload_only. Second: the endpoint rejects ?cost_mode= entirely (clusters, nodes, recommendations, etc., see Cost Modes).
json
1{
2  "data": null,
3  "meta": {
4    "request_id": "0196f1c2-31e9-7398-84d3-4e5ab140f123",
5    "applied_at": "2026-05-21T10:30:00Z"
6  },
7  "error": {
8    "code": "INVALID_COST_MODE",
9    "message": "cost_mode must be one of: fully_loaded, workload_only",
10    "details": [
11      {
12        "field": "cost_mode",
13        "allowed": ["fully_loaded", "workload_only"]
14      }
15    ]
16  }
17}

Not found (404)

Eight typed codes, one per resource family. This is intentional: branch on the code, not on a regex of the message. If you have a polymorphic ID resolver, switch over the code.

CodeReturned by
CLUSTER_NOT_FOUNDGET /v1/clusters/{id} and every route under it. See Routes under a cluster.
NAMESPACE_NOT_FOUND/v1/clusters/{id}/namespaces/{ns}.
WORKLOAD_NOT_FOUND/v1/workloads/{uid} and /v1/workloads/{uid}/pods.
NODE_NOT_FOUND/v1/nodes/{node_uid} (lookup by Kubernetes metadata.uid).
NODE_GROUP_NOT_FOUND/v1/clusters/{id}/node-groups/{name}.
RECOMMENDATION_NOT_FOUND/v1/recommendations/{id}.
TEAM_NOT_FOUND/v1/teams/{id} and assignment endpoints.
DEPARTMENT_NOT_FOUND/v1/departments/{id}.

A 404 means the ID was well-formed but no such resource exists in your tenant (or you can't see it under your key's scope). Don't retry a 404; the resource isn't coming back.

Routes under a cluster

Every route under /v1/clusters/{cluster_id} checks the cluster before it reads anything. The checks run in this order, and the first one that fails answers:

OrderHTTPerror.codeWhen
1403CLUSTER_ACCESS_DENIEDThe key may read no cluster at all. details[0].reason is no_cluster_in_scope.
2400INVALID_CLUSTER_IDcluster_id is not a UUID.
3403CLUSTER_ACCESS_DENIEDThe cluster is outside the key's allow-list. No details.
4404CLUSTER_NOT_FOUNDNo cluster with this ID exists in your organization.

A restricted key gets the 403 before the existence check, so it learns nothing about clusters it may not read. After these checks the route answers as its own page describes: an empty list means the cluster has no matching rows, and a child resource that does not exist returns its own 404 (NAMESPACE_NOT_FOUND, NODE_GROUP_NOT_FOUND).

Pagination (410)

CodeWhendetails[0].reason
CURSOR_EXPIREDCursor older than the 24h TTL.expired
CURSOR_EXPIREDCursor's bound query parameters don't match the current request.query_changed

See Pagination for the cursor model and the 24-hour TTL. Restart the walk with no cursor.

Rate and availability (429, 502, 503, 504)

CodeHTTPWhenRetry?
RATE_LIMITED429Per-key request quota exceeded. The response carries a Retry-After header (seconds).Yes, sleep Retry-After then retry.
RATE_LIMIT_UNAVAILABLE503Rate-limit enforcement is temporarily unavailable. Retry with exponential backoff.Yes, exponential backoff.
SERVICE_UNAVAILABLE503Reserved in the catalog. No endpoint returns it today.Yes, exponential backoff.
UPSTREAM_UNAVAILABLE502The service behind Cost Explorer or the FOCUS export returned an error or was unreachable.Yes, exponential backoff.
UPSTREAM_TIMEOUT504The request could not be computed in time: Cost Explorer (30-second deadline) or a cost trend (message: "narrow the time range or use a coarser granularity").Yes, with backoff. For cost trends, narrow the window first.
json
1{
2  "data": null,
3  "meta": {
4    "request_id": "0196f1c2-77ce-7e14-9ccd-2c38b7885473",
5    "applied_at": "2026-05-21T10:30:00Z"
6  },
7  "error": {
8    "code": "RATE_LIMITED",
9    "message": "rate limit exceeded",
10    "details": [
11      { "reason": "per_key_quota_exceeded" }
12    ]
13  }
14}

The Retry-After header is the source of truth; honor it before adding your own jitter on top.

Server (500)

CodeWhen
INTERNAL_ERRORUnhandled server error. Carries a request_id. Report persistent occurrences to support@kubeadapt.io with the request_id.

A 500 is retryable once or twice with backoff. Escalate to support if it persists.

Retry strategy

Six codes are retryable: RATE_LIMITED, RATE_LIMIT_UNAVAILABLE, SERVICE_UNAVAILABLE, UPSTREAM_UNAVAILABLE, UPSTREAM_TIMEOUT, and INTERNAL_ERROR. Every other code in the catalog is non-retryable.

Recommended behavior:

  1. Honor Retry-After. On 429 RATE_LIMITED, the header is 60 (the length of the sliding window); on 503 RATE_LIMIT_UNAVAILABLE it is 5. Every authenticated response also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; use them to slow down before you hit a 429.
  2. Use exponential backoff with jitter. For the other retryable codes, double the delay between attempts (e.g., 1s → 2s → 4s) and add randomized jitter to avoid synchronized retry storms.
  3. Cap attempts at 3-5. If the error persists past that, surface it; the issue is no longer transient.
  4. Never retry non-retryable codes. 400, 401, 403, 404, 410, 422 will not resolve by retrying. Repeating an authentication failure can lead to the key being blocked.

The details payload shape

error.details is an optional array. Each entry uses three documented keys plus free-form inline keys for case-specific data.

KeyTypeWhen present
fieldstringThe name of the parameter or body field that triggered the error (cost_mode, start, cluster_id).
reasonstringMachine-readable reason code (required, expired, query_changed, not_supported_on_this_endpoint, no_scopes_on_key, no_cluster_in_scope, ambiguous_workload_uid, ambiguous_node_uid, missing_header, key_not_found).
allowedarray of stringsThe allowed values when the field is enum-typed, or the cluster IDs to choose from when a UID is ambiguous.

Some details carry inline custom keys at the same level (no nested extra block):

  • FORBIDDEN → required (array of the scopes the endpoint accepts, e.g. ["clusters:read"]) and granted (array of the key's scopes).

Five worked details payloads:

json
1// 1. Validation, enum-typed field.
2{
3  "field": "cost_mode",
4  "allowed": ["fully_loaded", "workload_only"]
5}
6
7// 2. Cost mode rejected on a cluster-family endpoint.
8{
9  "field": "cost_mode",
10  "reason": "not_supported_on_this_endpoint"
11}
12
13// 3. Missing required parameter.
14{
15  "field": "start",
16  "reason": "required"
17}
18
19// 4. Cursor query mismatch.
20{
21  "reason": "query_changed"
22}
23
24// 5. Workload UID found in two clusters; retry with ?cluster_id=.
25{
26  "field": "cluster_id",
27  "reason": "ambiguous_workload_uid",
28  "allowed": ["a8d3e1b2-f4c6-4789-9bde-2345678901cd", "c1a2b3c4-d5e6-7890-abcd-ef1234567890"]
29}

Pre-flight checklist

Before shipping a client, verify it:

  • Branches on error.code, never on error.message.
  • Treats unknown codes as non-retryable.
  • Reads meta.request_id and logs it on every failed call.
  • Implements the retry strategy above with Retry-After support on 429.
  • Does NOT retry 400, 401, 403, 404, 410, or 422.

See also

  • API Overview, the envelope shape and the full endpoint index.
  • Pagination, where INVALID_CURSOR and CURSOR_EXPIRED come from.
  • Authentication, the Bearer-token model behind UNAUTHORIZED, FORBIDDEN, and CLUSTER_ACCESS_DENIED.
  • Cost Modes, the contract behind INVALID_COST_MODE on the cluster, node, and recommendation families.
  • Permission Scopes, the catalog of scopes that drives 403 responses.