Veefore docs

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.

bash
curl -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 with agent: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:

  1. Audience mismatch. The most common by a wide margin. The gateway compares aud as a string, so 127.0.0.1 and localhost are different values. Fetch the real one from /.well-known/veefore-platform-key.
  2. Wrong canonicalisation. Only if you are signing yourself. See Sign your own assertion.
  3. 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

ts
const 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

ts
catch (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:

bash
veefore logs --limit 50

And pass reason through to whoever is waiting. It is written to be read.

Identity, authority, policy and enforcement for autonomous AI agents.