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.
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.codeis the stable identifier. Branch on the code, never on the message string or the HTTP status alone.error.detailsis 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.
| HTTP | error.code | Family | Retryable? |
|---|---|---|---|
| 400 / 404 / 405 | BAD_REQUEST | Request shape. Also returned with 404 for a path that does not exist and with 405 for the wrong HTTP method. | No |
| 400 | INVALID_CURSOR | Pagination | No |
| 400 | INVALID_CLUSTER_ID | Validation | No |
| 401 | UNAUTHORIZED | Auth | No |
| 403 | FORBIDDEN | Auth | No |
| 403 | CLUSTER_ACCESS_DENIED | Auth (cluster ACL) | No |
| 404 | CLUSTER_NOT_FOUND | NotFound | No |
| 404 | NAMESPACE_NOT_FOUND | NotFound | No |
| 404 | WORKLOAD_NOT_FOUND | NotFound | No |
| 404 | NODE_NOT_FOUND | NotFound | No |
| 404 | NODE_GROUP_NOT_FOUND | NotFound | No |
| 404 | RECOMMENDATION_NOT_FOUND | NotFound | No |
| 404 | TEAM_NOT_FOUND | NotFound | No |
| 404 | DEPARTMENT_NOT_FOUND | NotFound | No |
| 410 | CURSOR_EXPIRED | Pagination | No (restart) |
| 422 | VALIDATION_ERROR | Validation | No |
| 422 | INVALID_COST_MODE | Validation | No |
| 429 | RATE_LIMITED | Rate limit | Yes (with backoff) |
| 500 | INTERNAL_ERROR | Server | Yes (with backoff) |
| 502 | UPSTREAM_UNAVAILABLE | Upstream (Cost Explorer, FOCUS export) | Yes (with backoff) |
| 503 | RATE_LIMIT_UNAVAILABLE | Rate-limit enforcement temporarily unavailable | Yes (with backoff) |
| 503 | SERVICE_UNAVAILABLE | Reserved; not returned today | Yes (with backoff) |
| 504 | UPSTREAM_TIMEOUT | Timeout (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.
| Code | What it means | What to do |
|---|---|---|
UNAUTHORIZED | The Authorization header is missing, malformed, or carries an invalid/expired token. | Recheck the Bearer header. Rotate the key if it was revoked. |
FORBIDDEN | The 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_DENIED | The 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:
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:
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.
| Code | When |
|---|---|
BAD_REQUEST | Generic malformed input: limit out of range, an unparseable number, a body that fails to parse. |
INVALID_CURSOR | The ?cursor= value is not base64url-decodable or its decoded JSON is malformed. Restart pagination. |
INVALID_CLUSTER_ID | A 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.
| Code | When |
|---|---|
VALIDATION_ERROR | General semantic error. details lists the field, the reason, and the allowed set when applicable. |
INVALID_COST_MODE | Two 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). |
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.
| Code | Returned by |
|---|---|
CLUSTER_NOT_FOUND | GET /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:
| Order | HTTP | error.code | When |
|---|---|---|---|
| 1 | 403 | CLUSTER_ACCESS_DENIED | The key may read no cluster at all. details[0].reason is no_cluster_in_scope. |
| 2 | 400 | INVALID_CLUSTER_ID | cluster_id is not a UUID. |
| 3 | 403 | CLUSTER_ACCESS_DENIED | The cluster is outside the key's allow-list. No details. |
| 4 | 404 | CLUSTER_NOT_FOUND | No 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)
| Code | When | details[0].reason |
|---|---|---|
CURSOR_EXPIRED | Cursor older than the 24h TTL. | expired |
CURSOR_EXPIRED | Cursor'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)
| Code | HTTP | When | Retry? |
|---|---|---|---|
RATE_LIMITED | 429 | Per-key request quota exceeded. The response carries a Retry-After header (seconds). | Yes, sleep Retry-After then retry. |
RATE_LIMIT_UNAVAILABLE | 503 | Rate-limit enforcement is temporarily unavailable. Retry with exponential backoff. | Yes, exponential backoff. |
SERVICE_UNAVAILABLE | 503 | Reserved in the catalog. No endpoint returns it today. | Yes, exponential backoff. |
UPSTREAM_UNAVAILABLE | 502 | The service behind Cost Explorer or the FOCUS export returned an error or was unreachable. | Yes, exponential backoff. |
UPSTREAM_TIMEOUT | 504 | The 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. |
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)
| Code | When |
|---|---|
INTERNAL_ERROR | Unhandled 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:
- Honor
Retry-After. On429 RATE_LIMITED, the header is60(the length of the sliding window); on503 RATE_LIMIT_UNAVAILABLEit is5. Every authenticated response also carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; use them to slow down before you hit a 429. - 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.
- Cap attempts at 3-5. If the error persists past that, surface it; the issue is no longer transient.
- Never retry non-retryable codes.
400,401,403,404,410,422will 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.
| Key | Type | When present |
|---|---|---|
field | string | The name of the parameter or body field that triggered the error (cost_mode, start, cluster_id). |
reason | string | Machine-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). |
allowed | array of strings | The 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"]) andgranted(array of the key's scopes).
Five worked details payloads:
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 onerror.message. - Treats unknown codes as non-retryable.
- Reads
meta.request_idand logs it on every failed call. - Implements the retry strategy above with
Retry-Aftersupport 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_CURSORandCURSOR_EXPIREDcome from. - Authentication, the Bearer-token model behind
UNAUTHORIZED,FORBIDDEN, andCLUSTER_ACCESS_DENIED. - Cost Modes, the contract behind
INVALID_COST_MODEon the cluster, node, and recommendation families. - Permission Scopes, the catalog of scopes that drives 403 responses.