Errors and retries
Which refusals you handle, which you surface, and which you must never retry.
One rule carries most of the weight:
Never retry a 403
A 403 is the server's answer, not a transport failure. The pipeline ran,
every stage was evaluated, and the result was a refusal. Calling again produces
the identical refusal, burns your rate limit, and fills the audit trail with
noise that makes the real decision harder to find.
Retry 429 and 503. Retry transport failures. Never retry a decision.
#The error envelope
json{
"error": {
"code": "AUTHORITY_EXCEEDED",
"message": "Requested action exceeds delegated authority.",
"request_id": "req_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"details": { "capability": "refund.create", "ceiling": 50000, "requested": 120000 }
}
}
Branch on code, never on message. Messages are written for humans and get
refined; codes are a contract. request_id is also the x-request-id header,
and it is the fastest way to find the matching action and audit records.
The whole code table is served at GET /v1/meta/error-codes with no
authentication, so a client can validate its own handling against the deployment
it is actually talking to.
#What to do, by class
| Status | Meaning | Retry | Handle by |
|---|---|---|---|
400 |
Malformed or inconsistent request | no | Fixing the request |
401 |
No usable identity | once, after re-authenticating | Then failing loudly |
403 |
Identity established, request refused | never | Reporting the reason |
404 |
No such resource in this tenant | no | Fixing the id |
409 |
Raced, or idempotency key reused | no | Reading current state first |
422 |
Well-formed, not satisfiable | no | Changing the request |
429 |
Rate limited | yes, with backoff | Honouring Retry-After |
500 |
Internal error | once | Reporting with request_id |
502 |
Downstream rejected it | depends on your downstream | Treating as a downstream failure |
503 |
A dependency is unavailable | yes, with backoff | Accepting that you are blocked |
504 |
Downstream timed out | only if idempotent | Checking state first |
#The refusals you will actually see
#ACTION_BLOCKED
Policy said no. explanation.matched_rule names the rule and
explanation.matched_rules carries the per-condition trace.
Report reason verbatim — it is operator-authored text from the policy rule and
it is meant to be read by whoever is affected. Do not retry.
#REQUIRE_APPROVAL → APPROVAL_REQUIRED
A human must decide. Not an error in the usual sense — an instruction to wait.
Use actWithApproval, or persist the approvalId and resume. Honour
pollAfterMs. See Handle approvals.
#AUTHORITY_EXCEEDED
The delegation chain resolved and the request is outside it. details carry the
capability, the ceiling and what was requested.
bashcurl -s https://veefore.example.com/v1/agents/agt_01ABC/authority \
-H "Authorization: Bearer $VEEFORE_API_KEY"
Read the resolved authority, not the individual grant — narrowing anywhere in the chain narrows everything below it.
#CAPABILITY_NOT_GRANTED vs TOOL_NOT_AUTHORIZED
Two halves of the same pair, kept distinct so you can tell which is missing.
CAPABILITY_NOT_GRANTED— the agent does not hold the capability. Grant it withagent:manage_capabilities.TOOL_NOT_AUTHORIZED— it holds the capability but is not authorized for this specific tool.POST /v1/tools/{toolId}/agents.
#AGENT_AUTHENTICATION_FAILED
Almost always one of:
- Audience mismatch. The most common by a wide margin. The gateway compares
audas a string, so127.0.0.1andlocalhostare different values. Fetch the real one from/.well-known/veefore-platform-key. - Wrong canonicalisation. Only if you are signing yourself. See Sign your own assertion.
- Clock drift. Beyond the acceptance window. Separately reported as
TIMESTAMP_OUT_OF_WINDOW.
#IDENTITY_GENERATION_STALE
The key was rotated. Re-read VEEFORE_AGENT_PRIVATE_KEY and
VEEFORE_AGENT_KEY_ID from your configuration source and reconnect. A
long-running process that caches configuration at startup needs a path for this.
#AGENT_IDENTITY_REVOKED
Terminal. The identity is gone and there is no recovery. Stop, alert a human. Do not retry, and do not build a fallback that works around it.
#NONCE_REPLAYED
An assertion was reused. Generate a fresh nonce for every assertion. If you are seeing this from an SDK, you are probably caching an assertion object.
#ENVIRONMENT_PAUSED / AGENT_NOT_OPERATIONAL / AGENT_ISOLATED
Someone deliberately stopped this. Back off substantially and surface it — a paused environment is an operator decision, and hammering it does nothing except make the logs harder to read during whatever they are dealing with.
#TOOL_DISABLED
The tool was turned off for everyone. Distinct from a policy BLOCK so your
logs say which happened.
#*_SERVICE_UNAVAILABLE
IDENTITY_SERVICE_UNAVAILABLE, POLICY_SERVICE_UNAVAILABLE,
APPROVAL_SERVICE_UNAVAILABLE, CREDENTIAL_SERVICE_UNAVAILABLE,
AUDIT_SERVICE_UNAVAILABLE.
These are fail-closed working
A dependency enforcement relies on is down, so protected actions are refused rather than waved through. There is no mode in which an error becomes an allow.
Retry with backoff. Do not build a bypass — a code path that proceeds when Veefore cannot evaluate is a code path an attacker will find and trigger deliberately.
#EXECUTION_FAILED and EXECUTION_TIMEOUT
Veefore permitted the action and the downstream rejected it or did not respond. The decision was allowed; the execution was not.
EXECUTION_TIMEOUT is the dangerous one: whether the downstream applied the
side effect is unknown. Which is precisely why mutating tools should be
idempotent and why you should send an Idempotency-Key. See
Idempotency.
#VELOCITY_LIMIT_EXCEEDED
The agent is acting faster than policy permits. Slow down. A retry loop here makes it worse by definition.
#Retry, correctly
tsconst RETRYABLE = new Set([429, 503]);
async function withRetry<T>(call: () => Promise<T>, attempts = 4): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await call();
} catch (error) {
const status = (error as { status?: number }).status;
const transport = status === undefined;
if (!transport && !RETRYABLE.has(status)) throw error; // 403 lands here
if (attempt === attempts - 1) throw error;
// Exponential with full jitter, so a fleet of agents does not
// synchronise its retries into a thundering herd.
const base = Math.min(1000 * 2 ** attempt, 15_000);
await new Promise((resolve) => setTimeout(resolve, Math.random() * base));
}
}
throw new Error('unreachable');
}
The SDKs already do this. maxRetries applies to transport errors only, and a
decision is never retried.
#Report refusals usefully
tscatch (error) {
if (error instanceof VeeforeBlockedError) {
logger.warn({
event: 'veefore.refused',
code: 'ACTION_BLOCKED',
requestId: error.decision.requestId,
actionId: error.decision.actionId,
rule: error.decision.explanation.matched_rule,
}, error.message);
return { status: 'refused', reason: error.message };
}
}
Log the requestId and the actionId. Those two turn "the agent was blocked"
into a one-command investigation:
bashveefore logs --limit 50
And pass reason through to whoever is waiting. It is written to be read.