GitHub
Reference

Errors

Single-field JSON error body, standard HTTP status codes, no retry-after on 429 by default.

ProxifAI returns errors as a single-field JSON body — no nested error codes, no separate details. Status codes follow REST conventions; the body just carries the human-readable message.

Error response shape

The canonical helper is utils.Error, which writes:

{
  "error": "team not found"
}

That’s it — one field. There’s no code, no details, no requestId. Some subsystems (LLM gateway, port proxy) add nested envelopes for compatibility:

{
  "error": {
    "message": "no LLM providers configured",
    "type": "gateway_error"
  }
}

…but this is the exception. Always check whether the body parses as a flat string field or an envelope before consuming it programmatically. The two surfaces:

ShapeWhere it’s used
{"error":"..."}Core REST API (/api/v1/{issues,projects,workflows,...}), forge, plan, platform
{"error":{"message":"...","type":"..."}}LLM gateway (/api/v1/llm/*) — OpenAI/Anthropic-shaped for SDK compatibility

Robust clients pull body.error?.message ?? body.error and treat both as strings.

HTTP status codes

CodeMeaningWhen it happens
200OKRead succeeded
201CreatedResource created (most POST endpoints)
202AcceptedAsync op enqueued (instances, dispatch — final state via subsequent GET)
204No ContentDelete or other success without a body
400Bad RequestValidation failure, malformed JSON, missing required field
401UnauthorizedMissing/invalid Authorization header — see Authentication
402Payment RequiredCredit check failed (Enterprise only — needs account-api)
403ForbiddenToken valid but lacks the scope or RBAC permission
404Not FoundResource doesn’t exist or you can’t see it (404 returned instead of 403 for tenant-isolated resources)
409ConflictUnique-key violation (duplicate slug, repo name, etc.)
412Precondition FailedIf-Match/If-Unmodified-Since mismatch (rare, mostly forge writes)
422Unprocessable EntitySemantic validation (state machine violation, e.g. merging a closed PR)
429Too Many RequestsLLM gateway rate limit only — see Rate Limits
500Internal Server ErrorUnexpected server failure — file an issue with the response body
502Bad GatewayUpstream provider failure (LLM gateway when all providers fail their circuits)
503Service UnavailableBoot phase or core dependency missing (Postgres unreachable, etc.)

Async operations

A handful of endpoints return 202 Accepted because the operation runs through a NATS-backed worker:

  • Issue dispatchPOST /api/v1/issues/{id}/dispatch returns 202 and the agentExecutionId; subscribe to /api/v1/agents/executions/{id}/events (SSE) for progress
  • Instance creationPOST /api/v1/instances (Enterprise / vm-manager) is async; poll the instance row until status settles
  • Workflow runPOST /api/v1/workflows/{id}/run returns the execution ID; poll GET /api/v1/workflows/{id}/executions/{execId} or stream GET /api/v1/workflows/{id}/executions/{execId}/stream

Pattern:

# 1. Kick off
EXEC_ID=$(curl -s -X POST http://localhost:3000/api/v1/issues/iss_42/dispatch \
  -H "Authorization: Bearer $PFAI_TOKEN" -d '{"agentId":"claude-code"}' \
  | jq -r '.executionId')

# 2. Block until terminal (issue dispatch creates an agent execution)
pfai wait agent-execution/$EXEC_ID
# or stream:
curl -N http://localhost:3000/api/v1/agents/executions/$EXEC_ID/events \
  -H "Authorization: Bearer $PFAI_TOKEN"

pfai wait <kind>/<id> (see CLI → Utility) is the canonical way to block until a terminal state — handles reconnects and surfaces a non-zero exit on failure.

Idempotency

Idempotency keys are only honored on the instances API (POST /api/v1/instances):

curl -X POST http://localhost:3000/api/v1/instances \
  -H "Authorization: Bearer $PFAI_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{...}'

The handler (internal/instances/api/handlers/instance.go) deduplicates retries inside a 24-hour window. Other POST endpoints don’t yet support Idempotency-Key — the header is parsed by the CORS middleware (allowed) but ignored.

For non-idempotent endpoints, dedupe at the application layer (use a deterministic identifier in the body, then ignore 409 conflicts).

Handling errors in clients

const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });

if (!res.ok) {
  const body = await res.json().catch(() => ({}));
  // Both flat and nested error envelopes:
  const message = body?.error?.message ?? body?.error ?? res.statusText;

  if (res.status === 401) throw new AuthError(message);
  if (res.status === 403) throw new PermissionError(message);
  if (res.status === 404) throw new NotFoundError(message);
  if (res.status === 429) {
    // Gateway-only — back off and retry
    await new Promise(r => setTimeout(r, 1000));
    return retry();
  }
  throw new ApiError(res.status, message);
}

See also