Skip to main content
All errors from /api/public/v1 follow predictable shapes. Understanding them lets your integration distinguish transient failures (worth retrying) from permanent failures (worth alerting on).

HTTP status code semantics

StatusMeaningIntegration action
400 Bad RequestMalformed request body or invalid field valuesFix the request — do not retry
401 UnauthorizedMissing, expired, or invalid API keyCheck key configuration — do not retry until fixed
403 ForbiddenValid key but missing scope, or operation not permittedCheck scope list — do not retry until fixed
404 Not FoundResource does not existDo not retry; the resource is genuinely absent
409 ConflictDuplicate submission (same request_id already exists)Treat as success — idempotent; read the existing record
422 Unprocessable EntityRequest body passes JSON parsing but fails validationFix the request — do not retry
429 Too Many RequestsRate limit exceededRetry after the Retry-After header delay
500 Internal Server ErrorUnexpected server errorRetry with exponential backoff
502 Bad GatewayUpstream dependency failureRetry with exponential backoff
503 Service UnavailableServer overloaded or in maintenanceRetry after Retry-After if present
504 Gateway TimeoutUpstream timeoutRetry with exponential backoff

Error envelope shapes

Authentication and authorization errors

FastAPI returns a plain JSON object for auth failures:
{
  "detail": "Missing authentication credentials"
}
Other detail strings you may see:
  • "Invalid authentication credentials" — bad or expired key
  • "API key lacks required scope" — key exists but scope is wrong
  • "Admin privileges required" — endpoint requires admin role

Validation errors (HTTP 422)

When request body validation fails, the server returns FastAPI’s standard validation error shape:
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "data", "attributes", "request_id"],
      "msg": "Field required",
      "input": {}
    }
  ]
}
loc is a list that traces the path to the failing field. type is a Pydantic error code. msg is a human-readable description.

Rate limit errors (HTTP 429)

{
  "detail": "Rate limit exceeded"
}
Check the Retry-After response header for the number of seconds to wait before retrying.

Server errors (HTTP 5xx)

{
  "detail": "Internal server error"
}
5xx errors are always safe to retry with backoff. The server is idempotent for POST /api/public/v1/requests — submitting the same request_id twice returns the existing record rather than a duplicate.

Retry classification

The Discourse plugin uses this classification (mirrors the server’s own webhook retry logic):
Retryable:    429, 500, 502, 503, 504
Non-retryable: 400, 401, 403, 404, 409, 422
Recommended backoff for retryable errors: 0.5 s × 2^(attempt - 1) with a cap at 60 s and a maximum of 3 attempts. This matches the Discourse plugin’s INITIAL_BACKOFF = 0.5 / MAX_RETRIES = 3 constants.

Example: handling errors in an integration

MAX_RETRIES = 3
INITIAL_BACKOFF = 0.5
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze

def request_with_retries(method, path, body: {})
  retries = 0
  loop do
    response = execute_request(method, path, body: body)
    if response.success?
      return response.body
    elsif RETRYABLE_STATUSES.include?(response.status) && retries < MAX_RETRIES
      retries += 1
      sleep(INITIAL_BACKOFF * (2 ** (retries - 1)))
    else
      raise ApiError.new(response.status, response.body)
    end
  end
end

Idempotency note

POST /api/public/v1/requests is idempotent on request_id. If you retry a request that already succeeded, the server returns HTTP 200 with the existing record rather than HTTP 201. Your integration should treat both 200 and 201 as success.

See also