NetStacksNetStacks

Error Responses

Reference for NetStacks API errors: the flat JSON error shape, every HTTP status code returned, rate-limit responses, and copy-paste handling examples.

Overview

The NetStacks Controller API returns a consistent JSON body for every error. The body is a flat object with a single error field containing a human-readable message. The meaning of the error is carried by the HTTP status code, not by a machine-readable code string in the body.

Build your client to branch on the HTTP status code. Treat the error message as a human-facing string to log or display — do not parse or pattern-match it, since the exact wording may change between releases.

No machine-readable error codes

The API does not emit symbolic error codes such as DEVICE_NOT_FOUND or VALIDATION_ERROR, and there is no error.code, error.status, or error.details field. The only fields you can rely on are the HTTP status and the flat error string.

Error Format

The Flat Error Object

All API errors return a JSON object with a single top-level error field:

error-body.jsonjson
{
  "error": "Device not found"
}

The HTTP status line tells you the category. For example, a request for a device that does not exist returns:

HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"Device not found"}

Examples of Real Messages

Messages are short and descriptive. A few you will encounter in practice:

  • 401 → {"error":"Authentication required"} (no token supplied)
  • 401 → {"error":"Invalid or expired token"} (bad, revoked, or expired token)
  • 403 → {"error":"Insufficient permissions"}
  • 404 → {"error":"Device not found"}
  • 409 → {"error":"Device with this name already exists"}
The 429 body is different

Rate-limit responses add a second field. See Rate Limiting below for the exact shape.

HTTP Status Codes

The API returns the following status codes. Branch your client on these — not on the message text.

StatusMeaningTypical causeHow to resolve
400Bad RequestMalformed JSON, missing/invalid field, or a value that failed validationFix the request body and headers. The error message describes what was wrong.
401UnauthorizedNo token supplied, or the token is invalid, revoked, or expiredSend a valid Authorization: Bearer header. If the token expired, refresh it (see below).
403ForbiddenAuthenticated, but the user lacks the required permissionReview the user's role assignments and permissions.
404Not FoundThe resource (device, template, task, etc.) does not existVerify the ID by listing the resource collection first.
409ConflictA resource with the same unique value (e.g. name) already existsUse a unique value or update the existing resource.
429Too Many RequestsClient exceeded the rate limitHonor the Retry-After header and back off.
500Internal Server ErrorUnexpected server-side failure (e.g. database error)Check the Controller logs; retry or report if persistent.
502Bad GatewayAn upstream dependency the Controller called returned an errorTransient in most cases — retry with backoff.
503Service UnavailableThe Controller is starting up or temporarily overloadedRetry with exponential backoff.
Retry only safe statuses

Retry 429, 502, and 503 with backoff. Do not auto-retry 400, 403, 404, or 409 — those indicate a request that will keep failing until you change it. A 500 may be retried once or twice but usually needs investigation.

Rate Limiting (429)

When a client exceeds the request rate limit, the Controller returns 429 Too Many Requests with a Retry-After header (seconds) and a body that includes an extra retry_after_secs field:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{
  "error": "Too many requests",
  "retry_after_secs": 30
}

The login endpoint applies a stricter, separate lockout after repeated failed sign-ins. When that lockout trips, the message differs but the shape is the same:

HTTP/1.1 429 Too Many Requests
Retry-After: 900
Content-Type: application/json

{
  "error": "Too many failed login attempts. Please try again later.",
  "retry_after_secs": 900
}
Prefer the header

Both the Retry-After header and the retry_after_secs body field carry the same value in seconds. Read whichever is convenient; the header is standard and works even if you do not parse the body.

Handling Errors

Inspecting Errors with curl

error-examples.shbash
# 401 - no/invalid token
curl -i https://netstacks.example.net/api/devices \
  -H "Authorization: Bearer invalid-or-expired-token"

# HTTP/1.1 401 Unauthorized
# {"error":"Invalid or expired token"}

# 400 - invalid request body
curl -i -X POST https://netstacks.example.net/api/devices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"test"}'   # missing required fields

# HTTP/1.1 400 Bad Request
# {"error":"<message describing the invalid request>"}

# 409 - duplicate name
# HTTP/1.1 409 Conflict
# {"error":"Device with this name already exists"}

# 429 - rate limited
# HTTP/1.1 429 Too Many Requests
# Retry-After: 30
# {"error":"Too many requests","retry_after_secs":30}

Refreshing an Expired Token

A 401 on a previously-working request usually means the access token expired. Exchange your refresh token for a new access token at POST /api/auth/refresh:

curl -X POST https://netstacks.example.net/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "<your-refresh-token>"}'

# 200 OK -> new access_token + refresh_token (refresh tokens are one-time use)
# 401 Unauthorized -> {"error":"Invalid or expired token"}
#   (refresh token also expired/revoked -> re-authenticate via /api/auth/login)

Python: Branch on Status, Retry Transient Errors

error-handling.pypython
import requests
import time

class NetStacksApiError(Exception):
    def __init__(self, status: int, message: str):
        self.status = status
        self.message = message
        super().__init__(f"[{status}] {message}")

RETRYABLE = {429, 502, 503}

def api_request(method: str, url: str, headers: dict, max_retries=3, **kwargs):
    """Call the API, retrying only transient (429/502/503) failures."""
    for attempt in range(max_retries + 1):
        resp = requests.request(method, url, headers=headers, **kwargs)

        if resp.ok:
            return resp.json() if resp.content else None

        # Flat error body: {"error": "<message>"} (429 adds retry_after_secs)
        body = resp.json() if resp.content else {}
        message = body.get("error", resp.reason)

        if resp.status_code in RETRYABLE and attempt < max_retries:
            # Honor Retry-After when present, else exponential backoff
            retry_after = resp.headers.get("Retry-After")
            wait = int(retry_after) if retry_after else min(2 ** attempt, 30)
            print(f"{resp.status_code} {message} - retrying in {wait}s")
            time.sleep(wait)
            continue

        raise NetStacksApiError(resp.status_code, message)

# Usage
try:
    devices = api_request("GET", f"{base_url}/devices", headers=headers)
except NetStacksApiError as e:
    if e.status == 401:
        print("Token rejected - refresh or re-authenticate")
    elif e.status == 403:
        print("Missing permission for this operation")
    else:
        print(f"Request failed: {e}")

TypeScript: Branch on Status, Retry Transient Errors

error-handling.tstypescript
interface ApiErrorBody {
  error: string;
  retry_after_secs?: number; // only present on 429
}

class NetStacksError extends Error {
  constructor(public status: number, message: string) {
    super(`[${status}] ${message}`);
  }
}

const RETRYABLE = new Set([429, 502, 503]);

async function apiRequest<T>(
  method: string,
  url: string,
  headers: Record<string, string>,
  options?: { body?: string; maxRetries?: number }
): Promise<T> {
  const maxRetries = options?.maxRetries ?? 3;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const resp = await fetch(url, { method, headers, body: options?.body });

    if (resp.ok) {
      return resp.status === 204 ? (null as T) : resp.json();
    }

    const body: ApiErrorBody = await resp.json().catch(() => ({ error: resp.statusText }));

    if (RETRYABLE.has(resp.status) && attempt < maxRetries) {
      const headerSecs = Number(resp.headers.get("Retry-After"));
      const waitMs = headerSecs
        ? headerSecs * 1000
        : Math.min(2 ** attempt * 1000, 30000);
      console.warn(`${resp.status} ${body.error} - retrying in ${waitMs}ms`);
      await new Promise((r) => setTimeout(r, waitMs));
      continue;
    }

    throw new NetStacksError(resp.status, body.error);
  }

  throw new Error("Max retries exceeded");
}

// Usage
try {
  const devices = await apiRequest("GET", `${BASE_URL}/devices`, headers);
} catch (e) {
  if (e instanceof NetStacksError) {
    if (e.status === 401) console.log("Refresh token or re-authenticate");
    else if (e.status === 403) console.log("Missing permission");
  }
}

Questions & Answers

What shape do API errors have?
A flat JSON object with one field: {"error":"<human-readable message>"}. There is no code, status, or details field in the body. The HTTP status line carries the category.
Are there machine-readable error codes I can switch on?
No. Switch on the HTTP status code instead (400, 401, 403, 404, 409, 429, 500, 502, 503). Treat the error string as a display/log value only — do not pattern-match it, as the wording can change.
How do I handle an expired token?
An expired token returns 401 with {"error":"Invalid or expired token"}. Call POST /api/auth/refresh with your refresh token to get a new access token (refresh tokens are single-use). If the refresh also returns 401, re-authenticate via POST /api/auth/login.
What does a validation failure return?
A 400 Bad Request with a message describing the problem. The API does not return 422 and does not include a field-level details object — read the error string to see what was wrong.
How should I handle rate limiting?
A 429 includes a Retry-After header and a retry_after_secs body field (same value, in seconds). Wait that long before retrying. Repeated failed logins trigger a separate, longer lockout on the login endpoint.
Which errors are safe to retry automatically?
429, 502, and 503 are transient — retry with backoff. 400, 403, 404, and 409 will keep failing until the request changes, so do not auto-retry them.
How do I debug a 500 Internal Server Error?
A 500 indicates an unexpected server-side failure (for example a database error). The body message is intentionally generic; check the Controller logs for the full trace, then retry or report it with the request method, URL, and body.

Troubleshooting

401 on Every Request

The token is missing, malformed, expired, or revoked — all of these return 401 with {"error":"Invalid or expired token"} (or {"error":"Authentication required"} when no header is sent). Confirm the Authorization: Bearer <token> header is present, then refresh or log in again. See API Authentication.

403 After a Token Refresh

A 403 with {"error":"Insufficient permissions"} means the user is authenticated but lacks the permission for that operation. If a role changed recently, the new access token reflects the current permissions — verify the user's roles still grant access.

400 on Resource Creation

Missing required fields or invalid values return 400. Read the error message to see what failed, then correct the request body and resend. There is no per-field details object to parse.

409 Conflict on Create

A unique value already exists — for example {"error":"Device with this name already exists"}. Choose a different name, or update the existing resource instead of creating a new one.

429 in Automated Scripts

Your client is sending requests too fast. Read the Retry-After header (or retry_after_secs) and pause before retrying. Space out and, where possible, batch requests so you stay under the limit.

500 / 502 / 503 During Deployments

500 points to a Controller-side failure, 502 to an upstream dependency error, and 503 to the Controller starting up or being overloaded. Check the Controller logs and retry 502/503 with backoff.