Docs

Errors & Retries

Error response format, status codes, rate limit handling, and retry strategies.

Public plugin setup and processing refusals

The curated public plugin returns tool results inside MCP JSON-RPC responses. Before activation, ordinary tools return CONNECTION_REQUIRED and the browser setup link. An active tool can return PROCESSING_UNAVAILABLE when the workspace cannot admit processing; read the returned reason, check the existing job's status, and review purchase receipts in Billing. See processing help. Read isError and structured content even when the HTTP request itself succeeds. An uncertain result is not proof of failure or permission to start another paid operation. Preserve the returned resource identity and check its status before retrying.

Errors & Retries

Most GridWonk API v1 endpoints return errors in a consistent envelope format. This page covers the error contract, rate limiting, and recommended retry strategies.

Error Response Envelope

Bounded parsers reject an oversized request with 413 PAYLOAD_TOO_LARGE before JSON, form, or PDF work starts. Malformed JSON uses the same safe error envelope with 400 INVALID_INPUT; internal exception details are kept in server logs rather than returned to callers.

Partner packet review, partner role decisions, and project PATCH updates are limited to 16 KiB before parsing. Callers should treat 413 as a request-shape error, not as a retryable server fault. A packet review POST with no body is valid when its optional review fields are omitted.

Utility notice write bodies have their own 32 KiB cap and reject unknown fields. Named notice transitions require an empty JSON object; callers cannot assign a status or approval identity.

Most JSON API errors follow this illustrative structure:

{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "Usage cap exceeded. Add a payment method to continue.",
    "paymentUrl": "https://gridwonk.com/console/billing",
    "reason": "hard_monthly_request_cap_exceeded"
  },
  "requestId": "req_a1b2c3d4e5f6g7h8"
}
FieldTypeDescription
error.codestringMachine-readable error code (see table below).
error.messagestringHuman-readable description of what went wrong. Safe to show a customer: it is the user-facing string, never internal server detail, and never empty — a failure with no specific explanation still returns a generic one. Quote requestId/correlationId when you need support to trace the underlying cause.
error.retryAfternumberSeconds to wait before retrying. Present on 429 responses and on retryable 503 responses (mirrored in the Retry-After header). The SDK surfaces it on every error class, not only rate limits.
error.paymentUrlstringBilling URL to resolve 402 PAYMENT_REQUIRED responses.
error.reasonstringMachine-readable denial reason (for rollout/billing gates, and for 503 SERVICE_UNAVAILABLE causes).
error.detailsobjectEndpoint-specific structured detail on some 400 responses. Project creation, for example, lists the utility's valid interconnection programs as { slug, label } objects under details.programs. Absent when the endpoint has nothing structured to add.
requestIdstring (optional)Correlation ID for this request when available. Include it in support tickets.

ZIP and document-processing recovery return 503 WORKFLOW_START_FAILED with Retry-After: 5 when the durable workflow cannot start. Retry the same archive finalize or intake retry-processing operation after that delay; do not create a replacement identity. Ineligible state or an exhausted three-cycle user processing-retry limit returns 409 CONFLICT; foreign or unauthorized resources return the shared 404 NOT_FOUND.

Route-specific exceptions:

  • Utility notice routes return generic 404 NOT_FOUND for missing, foreign, or inaccessible applications/notices. Invalid strict bodies or list filters return 400 INVALID_INPUT; stale lifecycle state returns 409 CONFLICT. Refresh the same notice before retrying a conflicted or uncertain send. 202 means unfinished delivery, not canonical sent status; a held delivery can return 200 and still require review. Do not change the recipient or create another request to bypass a hold. See notice operations.
  • Utility manual-decision writes return the same 404 NOT_FOUND for absent resources, foreign resources, and definite source-access denial. Stale package/version or concurrent study-evidence writes return 409 CONFLICT; refresh before retrying. Invalid strict-body fields return 400 INVALID_INPUT. An authorization-service outage remains a server failure, not a successful empty result or a permission denial. See utility decisions.
  • POST /api/v1/oauth/token uses OAuth error shape: { "error": "invalid_client", "error_description": "..." }.
  • That token route returns invalid_target when resource is not an exact GridWonk MCP endpoint; MCP authentication failures include a WWW-Authenticate challenge pointing to protected-resource metadata on the canonical GridWonk origin.
  • /api/v1/mcp JSON-RPC failures use { "jsonrpc": "2.0", "id": ..., "error": { "code": ..., "message": "...", "data": ... } }.
  • Unexpected failures from partner-scoped MCP packet tools return a generic Tool execution failed result plus the request ID. Internal database, storage, and workflow details stay in server logs.
  • Partner native-CAD intake returns the same 404 for an inaccessible submission, an organization outside the enabled cohort, or an inaccessible intake. A stale site-plan target returns 409; invalid extension, DWG header, or size uses the safe API error or status projection without exposing storage paths, source hashes, or internal parser details.
  • Reusing a native-CAD intake idempotency key with a different filename, byte count, or site-plan target returns 409; generate a new key for a different upload. If finalization accepted the bytes but could not start its durable workflow, it returns 503 workflow_start_failed with Retry-After: 5. Retry finalization with the same intake ID; atomic processing claims prevent the duplicate start from creating duplicate provider work.
  • Batch pairing can return HTTP 200 with status: "needs_selection" and authorized site-plan candidates. Choose one and repeat pairing with its exact version ID; a stale target returns 409. Hosted MCP returns that selection-needed result with isError: true. Standalone POST /api/v1/packets/cad-intake still requires a target. Native-CAD declarations and pairing on utility-owned packets return the shared 404.
  • Batch reservation replays return 200 and deduped: true; a changed manifest under the same key returns 409. Both batchId and batchItemId must be valid UUIDs on document creation (400 otherwise). Once an upload has advanced beyond its active upload attempt, poll its existing intake instead of creating another; a creation retry can return 409 at that stage.
  • Delegated-user resolution on /api/v1/mcp separates intentional delegation failures — a missing, invalid, or non-member delegation context, answered with HTTP 403, JSON-RPC -32600, and a controlled message — from internal resolution faults, which return a generic Unable to resolve effective user (HTTP 500, JSON-RPC -32603) and keep the underlying detail in server logs under the correlation ID.
  • On a later stateful MCP request, a key owner demoted below the delegated member, or a delegated member whose membership is revoked, makes the session unavailable before dispatch: HTTP 404, JSON-RPC -32001, MCP session not found. If the key owner's own membership is revoked, API-key authentication refuses the request earlier. Reinitialize after permissions are corrected; retrying the same session ID does not restore its former authority.
  • Bootstrap status errors can omit requestId; use the response status and message when no correlation ID is present.
  • Generic digitization and document-bound job creation return 404 NOT_FOUND for a Salesforce-owned document. Reading a source-owned copy does not authorize ordinary processing; retrying through the other generic endpoint does not bypass this restriction.
  • A utility Applications queue query matching more than 200 Salesforce-owned applications returns 400 INVALID_QUERY and asks for narrower filters. This bound applies before pagination: lowering limit alone does not fix it. The equivalent MCP tool returns an INVALID_QUERY tool error.

These safeguards do not activate the default-disabled Salesforce connector. When checking current source permissions, an authorization-service outage fails the read rather than returning a successful empty queue or an incomplete count.

Error Code Reference

Packet-job cancellation can succeed while returning a warning that the background runtime could not be stopped or a provider call may still finish. This is not a failed status write or permission to immediately retry the packet. Inspect current state and resolve the warning first. Already-terminal, out-of-scope or otherwise uncancellable jobs retain the existing 409 CONFLICT response; authentication and the error envelope are unchanged.

Packet upload recovery

An optional intake uploadState: "failed" describes a reported transfer problem, not a rejection of the source file or a failed document review. Read the existing intake before starting recovery: server-verified completion takes precedence over a late browser failure report.

If finalization returns 409 CONFLICT because the upload attempt changed, refresh the intake and use its current recovery path. Reauthorization retains the intake ID but creates a new byte-zero attempt; quota reacquisition after expiry can return 429 RATE_LIMITED. Respect Retry-After and do not create new identities to evade a quota. A batch that is abandoned or past its fixed lifetime stays closed history, but the file's own intake can still be retried. See safe recovery.

The remediation working-draft and export routes no longer emit a FEATURE_DISABLED code — their environment feature gates were removed on 2026-08-26 and the routes are always on.

CodeHTTP StatusWhen It Occurs
UNAUTHORIZED401Missing Authorization header, malformed Bearer token, expired key, or revoked key.
FORBIDDEN403API key is valid but not authorized for the requested endpoint (endpoint allowlist), or the organization is refused before the endpoint check: reason: api_access_suspended (a platform-admin suspension of the organization's API and MCP access, also returned while the deployment-wide brake is on) or reason: billing_suspended (the account is suspended). Both apply to the hosted MCP endpoint as well. Contact support; retrying will not help. Credentials that act as one person (user OAuth on the hosted MCP endpoint and the CLI bootstrap routes) also return reason: legal_acceptance_required while that person has not accepted the current legal documents; details.acceptUrl opens the page where they accept, and the same credential works again right after. Organization API keys are not refused for their own acceptance, but an MCP request that delegates to a person who has not accepted gets HTTP 403 with JSON-RPC error -32600 and a plain message instead of this body (see MCP error semantics).
PAYMENT_REQUIRED402Org-level hard usage cap (daily or monthly) exceeded and billing is not active. reason names the cap (see below) and paymentUrl opens the billing portal.
RATE_LIMITED429Per-minute or daily request limit exceeded for the API key, or an organization with active billing has reached its daily or monthly organization usage cap. Check retryAfter in the response body (also sent as the Retry-After header for organization caps).
INVALID_INPUT400Request body or query parameter validation failed. The message field lists specific issues.
NOT_FOUND404Resource does not exist or is not accessible within your organization scope.
CONFLICT409Invalid state transition, such as canceling an already-terminal job, or a concurrent update changed the records — refresh and retry.
UNPROCESSABLE422The record cannot accept the write in its current state (for example, assigning a content role to a linked document with no accepted sanitized version). Nothing changed concurrently, so retrying will not help — fix the underlying state first (for example, re-upload the document). The partner discovery spec declares this status on the affected endpoints.
LIMIT_EXCEEDED400Resource quota reached (e.g. maximum webhook subscriptions per organization).
CAPABILITY_RETIRED410A retired capability was called. Not retryable — the message names the supported replacement.
ENDPOINT_GONE410A retired endpoint was called. Not retryable — the body adds a machine-readable replacement path. See below.
SERVICE_UNAVAILABLE503A dependency the request needs could not be trusted, so nothing was written. Project writes use it when a utility's published interconnection programs could not be read (reason: program_lookup_failed — retry after retryAfter seconds) or are misconfigured (reason: program_publication_invalid — contact support; retrying will not help).
INTERNAL_ERROR500Server-side failure. Safe to retry with backoff.

GET /api/v1/openapi.json uses the same machine-credential authentication and error envelope as other broad API routes: a missing or invalid raw API key or scope-less OAuth JWT returns UNAUTHORIZED, while endpoint restrictions can return FORBIDDEN. The docs-host copy requires native internal authorization. Anonymous app-host /openapi.json and /.well-known/openapi.json publication remains disabled and returns 404; public help availability does not open those complete discovery documents.

The anonymous document names a schema for every status each operation declares, not just the success case. Packet operations reference the envelope above; the token exchange references the OAuth 2.0 { error, error_description } shape it actually returns. A generated client can therefore type its error branches without a second lookup.

Project reads deliberately return the same NOT_FOUND result for an unknown project and a project owned by another organization. Project updates return CONFLICT when the project is no longer an editable draft; refresh the record rather than retrying the stale write. An unexpected create failure, including a database insert that returns no record, stays behind the generic INTERNAL_ERROR response.

Retirement Responses (410)

Two distinct codes return 410. Both are permanent — retrying the same call will never succeed — but they differ in how the replacement is communicated.

CAPABILITY_RETIRED

Uses the standard error envelope, naming the replacement in prose:

{
  "error": {
    "code": "CAPABILITY_RETIRED",
    "message": "Legacy single-document validation is retired. Use packet intake Review."
  },
  "requestId": "req_a1b2c3d4e5f6g7h8"
}

Live today on:

  • POST /api/v1/documents/validate — the retired single-document validation tombstone. It stays authenticated and returns 410 rather than 404, so an old caller gets a definite permanent-retirement signal instead of a path that looks like a typo.
  • POST /api/v1/agents/execute with agentId: "remediation-agent" — the retired legacy remediation agent. Other agent IDs are unaffected.

ENDPOINT_GONE

Adds a machine-readable replacement path next to the envelope, so a client can route without parsing prose:

{
  "error": {
    "code": "ENDPOINT_GONE",
    "message": "This endpoint has been retired. It served the frozen legacy requirements corpus; use GET /api/v1/published-requirements for published requirements."
  },
  "replacement": "/api/v1/published-requirements",
  "requestId": "req_a1b2c3d4e5f6g7h8"
}

Read replacement and call that path instead.

ENDPOINT_GONE is live for the retired GET /api/v1/requirements. Authenticated calls receive HTTP 410 and the replacement path; the endpoint no longer reads the frozen corpus or emits its historical read webhook. See Requirements API — Retirement Stages.

Handling both

Treat 410 as a code path worth writing before an endpoint you depend on is retired. Neither code is retryable, so a generic 4xx backoff branch is the wrong home for either:

if (response.status === 410) {
  const body = await response.json();
  // ENDPOINT_GONE carries a machine-readable path; CAPABILITY_RETIRED explains in `message`.
  const target = body.replacement ?? null;
  alertOperators({
    code: body.error?.code,        // 'ENDPOINT_GONE' | 'CAPABILITY_RETIRED'
    detail: body.error?.message,
    replacement: target,
    requestId: body.requestId,
  });
  return; // Do not retry — 410 is permanent.
}

Rate Limiting

GridWonk enforces two rate limits per API key:

  • Per-minute limit: Configurable per key (default 60 requests/minute). Uses a fixed-window counter.
  • Daily limit: Derived as rateLimitPerMinute * 1,440 (minutes per day). Resets at midnight UTC.

Authentication and pre-auth alert throttles are separate from API-key usage limits and use shared Redis state in production. A forwarded client address is used only when the deployment explicitly configures a trusted edge header; ordinary caller-supplied forwarding headers do not choose the security bucket.

Organization Usage Caps

Every authenticated request is counted on a per-organization ledger, first for the current UTC day and then for the current month, on every billing status. When a cap refuses, error.reason is one of:

ReasonWindowCap
hard_daily_request_cap_exceededUTC dayDaily request count
hard_daily_cost_cap_exceededUTC dayDaily estimated cost (USD)
hard_monthly_request_cap_exceededCalendar month (UTC)Monthly request count
hard_monthly_cost_cap_exceededCalendar month (UTC)Monthly estimated cost (USD)

The status depends on billing: an organization with active billing (or an active manual override) receives 429 RATE_LIMITED with retryAfter (seconds to the next UTC day or month) mirrored in the Retry-After header; an organization without active billing receives 402 PAYMENT_REQUIRED with the same reason and a paymentUrl. Cost caps are only enforced when the deployment runs cost enforcement in hard mode; by default they are recorded without refusing. The same split applies on the hosted MCP endpoint for both credential lanes.

Rate Limit Headers

API-key-authenticated handler responses that pass auth, organization-profile, rate-limit, and organization-usage gates include these headers:

HeaderDescription
X-RateLimit-LimitDaily request cap for this API key.
X-RateLimit-RemainingRequests remaining in the current daily window.
X-RateLimit-ResetUnix timestamp (seconds) when the daily counter resets (midnight UTC).
X-Request-IdCorrelation ID matching the requestId in error response bodies.

Handling 429 Responses

When you receive a 429, the retryAfter field tells you how many seconds to wait. For per-minute limits, this is the time remaining in the current minute window. For daily limits, this is the time until midnight UTC. For an organization usage cap, it is the time until the refusing window (UTC day or month) rolls over.

async function fetchWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 3
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      return response;
    }

    // Use server-provided retryAfter, or exponential backoff
    const body = await response.json();
    const retryAfter = body.error?.retryAfter ?? Math.pow(2, attempt);
    const jitter = Math.random() * 1000;

    await new Promise((resolve) =>
      setTimeout(resolve, retryAfter * 1000 + jitter)
    );
  }

  throw new Error("Unreachable");
}

SSE Stream Error Handling

The /api/v1/jobs/{jobId}/stream, /api/v1/agents/chat, and enabled /api/v1/assist endpoints use Server-Sent Events. Errors during a stream are delivered as error events:

event: error
data: {"code":"INTERNAL_ERROR","message":"Agent execution failed","requestId":"req_abc123"}

The job stream requires a Bearer credential. Native browser EventSource cannot set the Authorization header, so use authenticated fetch, an SSE-capable client that supports headers, the SDK, or the CLI. This runnable TypeScript helper parses authenticated job events:

async function consumeJobStream(streamUrl: string, accessToken: string) {
  const response = await fetch(streamUrl, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'text/event-stream',
    },
  });

  if (!response.ok || !response.body) {
    throw new Error(`Stream request failed: ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';

    for (const frame of frames) {
      const lines = frame.split('\n');
      const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
      const data = lines
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');

      if (!event || !data) continue;
      const payload = JSON.parse(data);

      if (event === 'error') {
        throw new Error(`Stream error [${payload.code}]: ${payload.message}`);
      }
      if (event === 'job.complete') return payload;
      if (event === 'timeout') return { timedOut: true };
    }
  }

  throw new Error('Stream closed before a terminal event');
}

The job stream emits a timeout event when that stream connection reaches its duration limit. It does not mean the underlying job failed; reconnect or poll GET /api/v1/jobs/{jobId} to continue watching.

The assist stream emits content_block_delta events for text chunks. Its final done event includes the assembled text, resolved model identifier, provider, source, and normalized token usage. If generation fails after the stream starts, it emits event: error: unexpected provider failures use ASSIST_STREAM_FAILED, while a disconnected client uses REQUEST_CANCELLED and the bounded host deadline uses ASSIST_TIMEOUT.

Idempotency

Webhook Event Delivery

Webhook payloads include an X-GridWonk-Event-Id header. Use this to deduplicate deliveries, since GridWonk retries failed webhook attempts. Store processed event IDs and skip duplicates:

app.post("/webhooks/gridwonk", (req, res) => {
  const eventId = req.headers["x-gridwonk-event-id"];
  if (alreadyProcessed(eventId)) {
    return res.status(200).send("OK"); // Acknowledge duplicate
  }
  processEvent(req.body);
  markProcessed(eventId);
  res.status(200).send("OK");
});

Job Deduplication

Creating a job for a utility and job type that already has an active (non-terminal) run returns the existing job instead of creating a duplicate. This is safe for retry logic -- if a POST /api/v1/jobs request times out, retrying with the same parameters will return the in-progress job.

Best Practices

  1. Always check error.code, not HTTP status alone. Multiple error codes can share the same status (e.g. RATE_LIMITED and LIMIT_EXCEEDED are both client errors).
  2. Log requestId when present. This is the fastest way to trace issues with GridWonk support, but early authentication and gate failures can omit it.
  3. Respect retryAfter. Ignoring it and retrying immediately will extend your lockout.
  4. Use exponential backoff with jitter for 500 errors. Start at 1 second, cap at 30 seconds.
  5. Set timeouts on SSE connections. If no events arrive within 5 minutes, reconnect and poll the job status via GET /api/v1/jobs/{jobId}.
  6. Deduplicate webhook events using X-GridWonk-Event-Id to ensure at-least-once delivery does not cause duplicate processing.

Authorization failures are terminal for the presented credential. A 403 caused by an API-key endpoint policy is not a transient error; do not retry it with the same key or broaden a key's allowlist without an explicit operator decision.

Salesforce connector retries

The separate outbound-only installed-app route currently returns only 503 {"error":"disabled"} with Cache-Control: no-store. This is an intentional code gate, not a transient failure to retry. Its small contract-specific envelope is not the partner API error envelope described above.

The default-disabled connector uses 400 for invalid strict inputs, 403 for denied source access, 404 for an inaccessible import reference, 409 for known conflicting or not-ready operations, and 503 for an unknown dependency failure. Existing authentication, usage, and rate-limit refusals still apply. After an uncertain import response, explicitly retry with the same idempotencyKey and acknowledge: true; the SDK does not auto-retry that POST. Errors do not expose provider response bodies or credentials.

Disabled hosted assistance

When GRIDBOOST_ENABLE_API_ASSIST is off, REST POST /api/v1/assist returns 404 NOT_FOUND. Authenticated MCP discovery omits assist, and a direct MCP call returns 400 with JSON-RPC code -32602 before assistant execution. A catalog entry in public, unauthenticated documentation does not establish availability for the current deployment or credential.

On this page