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"
}| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable error code (see table below). |
error.message | string | Human-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.retryAfter | number | Seconds 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.paymentUrl | string | Billing URL to resolve 402 PAYMENT_REQUIRED responses. |
error.reason | string | Machine-readable denial reason (for rollout/billing gates, and for 503 SERVICE_UNAVAILABLE causes). |
error.details | object | Endpoint-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. |
requestId | string (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_FOUNDfor missing, foreign, or inaccessible applications/notices. Invalid strict bodies or list filters return400 INVALID_INPUT; stale lifecycle state returns409 CONFLICT. Refresh the same notice before retrying a conflicted or uncertain send.202means unfinished delivery, not canonicalsentstatus; a held delivery can return200and 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_FOUNDfor absent resources, foreign resources, and definite source-access denial. Stale package/version or concurrent study-evidence writes return409 CONFLICT; refresh before retrying. Invalid strict-body fields return400 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/tokenuses OAuth error shape:{ "error": "invalid_client", "error_description": "..." }.- That token route returns
invalid_targetwhenresourceis not an exact GridWonk MCP endpoint; MCP authentication failures include aWWW-Authenticatechallenge pointing to protected-resource metadata on the canonical GridWonk origin. /api/v1/mcpJSON-RPC failures use{ "jsonrpc": "2.0", "id": ..., "error": { "code": ..., "message": "...", "data": ... } }.- Unexpected failures from partner-scoped MCP packet tools return a generic
Tool execution failedresult plus the request ID. Internal database, storage, and workflow details stay in server logs. - Partner native-CAD intake returns the same
404for an inaccessible submission, an organization outside the enabled cohort, or an inaccessible intake. A stale site-plan target returns409; 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 returns503 workflow_start_failedwithRetry-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
200withstatus: "needs_selection"and authorized site-plan candidates. Choose one and repeat pairing with its exact version ID; a stale target returns409. Hosted MCP returns that selection-needed result withisError: true. StandalonePOST /api/v1/packets/cad-intakestill requires a target. Native-CAD declarations and pairing on utility-owned packets return the shared404. - Batch reservation replays return
200anddeduped: true; a changed manifest under the same key returns409. BothbatchIdandbatchItemIdmust be valid UUIDs on document creation (400otherwise). Once an upload has advanced beyond its active upload attempt, poll its existing intake instead of creating another; a creation retry can return409at that stage. - Delegated-user resolution on
/api/v1/mcpseparates intentional delegation failures — a missing, invalid, or non-member delegation context, answered with HTTP403, JSON-RPC-32600, and a controlled message — from internal resolution faults, which return a genericUnable to resolve effective user(HTTP500, 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_FOUNDfor 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_QUERYand asks for narrower filters. This bound applies before pagination: loweringlimitalone does not fix it. The equivalent MCP tool returns anINVALID_QUERYtool 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.
| Code | HTTP Status | When It Occurs |
|---|---|---|
UNAUTHORIZED | 401 | Missing Authorization header, malformed Bearer token, expired key, or revoked key. |
FORBIDDEN | 403 | API 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_REQUIRED | 402 | Org-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_LIMITED | 429 | Per-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_INPUT | 400 | Request body or query parameter validation failed. The message field lists specific issues. |
NOT_FOUND | 404 | Resource does not exist or is not accessible within your organization scope. |
CONFLICT | 409 | Invalid state transition, such as canceling an already-terminal job, or a concurrent update changed the records — refresh and retry. |
UNPROCESSABLE | 422 | The 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_EXCEEDED | 400 | Resource quota reached (e.g. maximum webhook subscriptions per organization). |
CAPABILITY_RETIRED | 410 | A retired capability was called. Not retryable — the message names the supported replacement. |
ENDPOINT_GONE | 410 | A retired endpoint was called. Not retryable — the body adds a machine-readable replacement path. See below. |
SERVICE_UNAVAILABLE | 503 | A 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_ERROR | 500 | Server-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 returns410rather than404, so an old caller gets a definite permanent-retirement signal instead of a path that looks like a typo.POST /api/v1/agents/executewithagentId: "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:
| Reason | Window | Cap |
|---|---|---|
hard_daily_request_cap_exceeded | UTC day | Daily request count |
hard_daily_cost_cap_exceeded | UTC day | Daily estimated cost (USD) |
hard_monthly_request_cap_exceeded | Calendar month (UTC) | Monthly request count |
hard_monthly_cost_cap_exceeded | Calendar 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:
| Header | Description |
|---|---|
X-RateLimit-Limit | Daily request cap for this API key. |
X-RateLimit-Remaining | Requests remaining in the current daily window. |
X-RateLimit-Reset | Unix timestamp (seconds) when the daily counter resets (midnight UTC). |
X-Request-Id | Correlation 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
- Always check
error.code, not HTTP status alone. Multiple error codes can share the same status (e.g.RATE_LIMITEDandLIMIT_EXCEEDEDare both client errors). - Log
requestIdwhen present. This is the fastest way to trace issues with GridWonk support, but early authentication and gate failures can omit it. - Respect
retryAfter. Ignoring it and retrying immediately will extend your lockout. - Use exponential backoff with jitter for
500errors. Start at 1 second, cap at 30 seconds. - Set timeouts on SSE connections. If no events arrive within 5 minutes, reconnect and poll the job status via
GET /api/v1/jobs/{jobId}. - Deduplicate webhook events using
X-GridWonk-Event-Idto 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.