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.
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": "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"}
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.
| Status | Meaning | Typical cause | How to resolve |
|---|---|---|---|
| 400 | Bad Request | Malformed JSON, missing/invalid field, or a value that failed validation | Fix the request body and headers. The error message describes what was wrong. |
| 401 | Unauthorized | No token supplied, or the token is invalid, revoked, or expired | Send a valid Authorization: Bearer header. If the token expired, refresh it (see below). |
| 403 | Forbidden | Authenticated, but the user lacks the required permission | Review the user's role assignments and permissions. |
| 404 | Not Found | The resource (device, template, task, etc.) does not exist | Verify the ID by listing the resource collection first. |
| 409 | Conflict | A resource with the same unique value (e.g. name) already exists | Use a unique value or update the existing resource. |
| 429 | Too Many Requests | Client exceeded the rate limit | Honor the Retry-After header and back off. |
| 500 | Internal Server Error | Unexpected server-side failure (e.g. database error) | Check the Controller logs; retry or report if persistent. |
| 502 | Bad Gateway | An upstream dependency the Controller called returned an error | Transient in most cases — retry with backoff. |
| 503 | Service Unavailable | The Controller is starting up or temporarily overloaded | Retry with exponential backoff. |
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
}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
# 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
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
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 nocode,status, ordetailsfield 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
errorstring 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
401with{"error":"Invalid or expired token"}. CallPOST /api/auth/refreshwith your refresh token to get a new access token (refresh tokens are single-use). If the refresh also returns401, re-authenticate viaPOST /api/auth/login. - What does a validation failure return?
- A
400 Bad Requestwith a message describing the problem. The API does not return422and does not include a field-level details object — read theerrorstring to see what was wrong. - How should I handle rate limiting?
- A
429includes aRetry-Afterheader and aretry_after_secsbody 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, and503are transient — retry with backoff.400,403,404, and409will keep failing until the request changes, so do not auto-retry them.- How do I debug a 500 Internal Server Error?
- A
500indicates 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.
Related Features
- API Authentication — Login, token refresh, and the
Authorizationheader. - Devices API — Device endpoints that return 404/409 errors.
- Templates API — Template management and rendering.
- Stacks API — Stack deployment and management.
- Tasks API — Scheduled task management.