API Reference
Error Handling
The full Kubeadapt /v1 error code catalog, error families, retry rules, the details payload shape, and the universal error envelope.
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": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9F",
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 (one physical bill)",
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 | BAD_REQUEST | Request shape | 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 only) | Yes (with backoff) |
| 503 | RATE_LIMIT_UNAVAILABLE | Rate-limit enforcement temporarily unavailable | Yes (with backoff) |
| 503 | SERVICE_UNAVAILABLE | Service temporarily unavailable | Yes (with backoff) |
| 504 | UPSTREAM_TIMEOUT | Upstream timeout (Cost Explorer only) | 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). | Mint a new key with the right scopes. The details block lists what the endpoint required. |
CLUSTER_ACCESS_DENIED | The token has the right scope at the org level, but is not allowed on the specific cluster you queried. | Add the cluster to the key's allow-list, or use a different key. |
A FORBIDDEN response carries the required scope inline:
1{
2 "data": null,
3 "meta": {
4 "request_id": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9G",
5 "applied_at": "2026-05-21T10:30:00Z"
6 },
7 "error": {
8 "code": "FORBIDDEN",
9 "message": "API key lacks the required scope",
10 "details": [
11 { "reason": "missing_scope", "required": "clusters:read" }
12 ]
13 }
14}A CLUSTER_ACCESS_DENIED response names the cluster:
1{
2 "data": null,
3 "meta": {
4 "request_id": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9H",
5 "applied_at": "2026-05-21T10:30:00Z"
6 },
7 "error": {
8 "code": "CLUSTER_ACCESS_DENIED",
9 "message": "key is not authorized on this cluster",
10 "details": [
11 { "cluster_id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890" }
12 ]
13 }
14}Request shape (400)
Returned when the request is parseable but the inputs are malformed before any business logic runs. UUIDs that aren't UUIDs, unknown filter keys, cursors that fail base64 decode, limit=abc.
| Code | When |
|---|---|
BAD_REQUEST | Generic malformed input. Unknown query parameter, limit out of range, body 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). |
1{
2 "data": null,
3 "meta": {
4 "request_id": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9J",
5 "applied_at": "2026-05-21T10:30:00Z"
6 },
7 "error": {
8 "code": "BAD_REQUEST",
9 "message": "unknown filter key",
10 "details": [
11 { "field": "frobnicate", "reason": "unknown_filter_key" }
12 ]
13 }
14}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": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9K",
5 "applied_at": "2026-05-21T10:30:00Z"
6 },
7 "error": {
8 "code": "INVALID_COST_MODE",
9 "message": "cost_mode must be fully_loaded or workload_only",
10 "details": [
11 {
12 "field": "cost_mode",
13 "reason": "must_be_enum",
14 "allowed": ["fully_loaded", "workload_only"]
15 }
16 ]
17 }
18}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 | /v1/clusters/{id} and any nested endpoint when the cluster does not exist in your tenant. |
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.
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 | A part of Kubeadapt's service is temporarily unavailable. | Yes, exponential backoff. |
UPSTREAM_UNAVAILABLE | 502 | An upstream service backing POST /v1/cost-explorer/query returned 5xx or was unreachable. | Yes, exponential backoff. |
UPSTREAM_TIMEOUT | 504 | An upstream call backing POST /v1/cost-explorer/query exceeded the 60-second deadline. | Yes, exponential backoff. |
1{
2 "data": null,
3 "meta": {
4 "request_id": "req_01J5K3V0Q7Y4XR8A2B3C5D7E9L",
5 "applied_at": "2026-05-21T10:30:00Z"
6 },
7 "error": {
8 "code": "RATE_LIMITED",
9 "message": "request quota exceeded for this key",
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 carries the exact wait in seconds. Sleep at least that long before retrying. - 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, limit, frobnicate). |
reason | string | Machine-readable reason code (must_be_enum, expired, query_changed, unknown_filter_key, not_supported_on_this_endpoint). |
allowed | array of strings | The allowed values when the field is enum-typed. |
Some details carry inline custom keys at the same level (no nested extra block):
FORBIDDEN→required(the missing scope, e.g."clusters:read").CLUSTER_ACCESS_DENIED→cluster_id(the cluster the key cannot reach).
Four worked details payloads:
1// 1. Validation, enum-typed field.
2{
3 "field": "cost_mode",
4 "reason": "must_be_enum",
5 "allowed": ["fully_loaded", "workload_only"]
6}
7
8// 2. Cost mode rejected on a cluster-family endpoint.
9{
10 "field": "cost_mode",
11 "reason": "not_supported_on_this_endpoint"
12}
13
14// 3. Unknown filter key on a list endpoint.
15{
16 "field": "frobnicate",
17 "reason": "unknown_filter_key"
18}
19
20// 4. Cursor query mismatch.
21{
22 "reason": "query_changed"
23}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.