Errors and Retries
Error envelopes, per-surface error codes, retry strategy, idempotency, and observability for ESM partners.
Error envelope
Every Voke REST error response follows the same JSON shape:
{
"statusCode": 400,
"message": "Human-readable description",
"error": "Bad Request",
"code": "VALIDATION_ERROR",
"details": ["field: message"]
}The code field is the stable contract — it is an ApiErrorCode enum string that will not change between minor releases. message and details are informational and may change. Build error-handling logic against code, not message.
For the full list of all codes and their HTTP status mappings, see Reference / Error Codes.
REST errors partners will encounter
These are the ApiErrorCode values a partner is likely to encounter when calling Voke's REST API (API key management, trading config, org-context endpoints).
| Code | HTTP | When it occurs |
|---|---|---|
UNAUTHORIZED | 401 | Missing/expired API key, or (on partner endpoints) missing X-API-Key header. |
FORBIDDEN | 403 | Valid credentials but insufficient scopes for the resource. |
BAD_REQUEST | 400 | Generic malformed request — content-type mismatch, broken JSON, etc. |
VALIDATION_ERROR | 400 | Request body or query params failed schema validation. details[] lists offending fields. |
INVALID_UUID | 400 | A path or query parameter expected a UUID but received something else. |
EXTERNAL_PLANT_ID_INVALID_FORMAT | 400 | externalPlantId doesn't match the partner-facing identifier format. |
PAYLOAD_TOO_LARGE | 413 | Request body exceeds the per-route limit (5 MB on /api/v1/* by default). |
NOT_FOUND | 404 | Generic 404 for non-VCP routes. |
SITE_NOT_FOUND | 404 | The siteId either does not exist or is not visible to your API key's org. Cross-org access returns this same 404 — never a 403 — to avoid leaking the existence of other-org sites. |
PLANT_NOT_FOUND | 404 | Same as SITE_NOT_FOUND but for the older /plants/* route family (kept alongside /vcp/sites/* while consumers migrate). |
CONFLICT | 409 | Unique constraint violation (e.g. duplicate API key name). |
API_KEY_NOT_FOUND | 404 | Revoke/lookup of a non-existent API key. |
API_KEY_SCOPES_EMPTY | 400 | Mint request contains no scopes. |
API_KEY_SCOPE_INVALID | 400 | One or more requested scopes are not recognized. |
API_KEY_CIDR_NOT_IMPLEMENTED | 400 | The mint request's allowedIps uses a CIDR shape the server doesn't yet support. |
API_KEY_VCP_SCOPES_NOT_SUPPORTED | 400 | The deployment isn't provisioning per-key vhosts yet, so vcp:* scopes can't be granted to new keys. |
TRADING_NOT_ENABLED | 400 | Trading has not been enabled for the organization. |
TRADING_CONFIG_INCOMPLETE | 400 | Trading is enabled but required config fields (ESM URL, credentials) are missing. |
ESM_NOT_CONFIGURED | 400 | The org has no TradingPartnerConfig row. |
PLANT_NOT_LINKED_TO_ESM | 400 | The plant does not have an external_plant_id set. |
TOO_MANY_REQUESTS | 429 | Per-IP rate limit exceeded. A Retry-After header in seconds may be present (best-effort: depends on the throttler middleware on the route). Wait at least that long, then retry once. |
SERVICE_UNAVAILABLE | 503 | Downstream dependency (broker, database) is unreachable. Retry with exponential backoff. |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error. Safe to retry with exponential backoff. |
VCP command rejection codes
When Voke rejects a command, the AMQP ack message (event.command.ack) has status: "REJECTED" and a rejectionCode from the RejectionCode enum.
rejectionCode | Meaning |
|---|---|
CONSTRAINT_VIOLATION | The command value violates a site constraint (e.g. setpoint outside allowed range) |
INVALID_COMMAND | The command type is not supported for this site/device |
INVALID_PAYLOAD | The command payload is structurally malformed |
DEVICE_OFFLINE | The target device is not reachable |
DEVICE_FAULT | The target device is in a fault state and cannot accept commands |
UNSUPPORTED_FOR_TOPOLOGY | The command is valid in general but not supported for this plant's physical topology |
Never retry a rejected command with the same messageId and the same payload. A REJECTED ack is a semantic refusal, not a transient failure. Change the payload (e.g. bring setpoint within range) or escalate to operations before retrying.
AMQP auth failures
AMQP authentication and authorization failures are not HTTP errors — they occur at the RabbitMQ protocol layer during connection or channel open. Voke uses rabbitmq_auth_backend_http to validate credentials server-side.
| Failure | Visible as | Cause |
|---|---|---|
| TLS 1.2 negotiated | TLS handshake failure (protocol_version / SSLHandshakeException) before AMQP auth | The broker is TLS 1.3-only. A client that negotiates TLS 1.2 is rejected — e.g. Spring ssl.enabled: true without ssl.algorithm: TLSv1.3, or the RabbitMQ Java client's no-arg useSslProtocol(). Pin TLS 1.3 explicitly. |
| Wrong username (slug) or API key | AMQP 403 Connection refused | Credentials not found or not associated with vcp:connect scope |
| API key revoked | AMQP 403 Connection refused | Key was deleted on the Voke side; re-create and update consumer config |
| Wrong vhost | AMQP 403 Access refused | Use the per-key vhost partner-{keyId} from your connection bundle; never fall back to / (the default vhost). Every vcp:* key auto-provisions its own vhost, already embedded in the bundle's connection URI. |
| Queue name mismatch | AMQP 403 Access refused | Consumer declared a queue outside vcp.{slug}.* namespace |
| Missing publish scope | AMQP 403 Access refused | Publishing route requires a matching vcp:write:* scope |
| Missing/invalid HMAC | Message is accepted by broker then dropped by Voke | command.device, command.mode, and schedule.* require a valid envelope signature |
These failures are logged on the Voke side in the AmqpAuthController. Partners see only the protocol-level refusal. If a connection that previously worked suddenly starts failing, the most likely cause is an API key rotation or revocation — create a new key with vcp:connect scope and update your consumer.
Retry guidance
REST
| Response class | Strategy |
|---|---|
| 4xx (except 429) | Do not retry. Fix the request payload, credentials, or org context. |
| 429 Too Many Requests | Wait for the Retry-After value (seconds) from the response header, then retry once. |
| 5xx | Retry with exponential backoff: 1 s, 2 s, 4 s, 8 s, 16 s. Max 5 attempts. Abandon and alert if all fail. |
VCP (AMQP messages)
| Scenario | Strategy |
|---|---|
status: "ACCEPTED" or "QUEUED" ack received | Wait for event.execution before concluding the command outcome. |
status: "PARTIAL" ack received | Inspect results[]; some batch items were accepted and some rejected. |
status: "REJECTED" ack received | Do not retry. Change payload or escalate. |
| No ack within your timeout | Check whether the messageId was already processed (consult your own log). If not, you may retry with a new messageId after confirming the site is reachable. |
| Consumer reconnect | RabbitMQ will redeliver all unacknowledged messages. Expect duplicates. Dedupe by messageId on the consumer side. |
event.execution delayed or absent | This is a fan-out timing issue on Voke's side — do not speculatively re-send the command. Wait at least 30 s for late event.execution delivery before escalating. |
AMQP connection drops
Reconnect immediately (no wait on first attempt), then apply backoff on subsequent failures. Queues are durable and messages are persistent — nothing is lost while you are disconnected. Unacked messages redeliver automatically when you reconnect.
Idempotency
- Always set a unique
messageIdin every VCP envelope you send. Use a UUID v4 or a collision-resistant identifier. ThemessageIdis your primary handle for correlating acks and statuses. - Voke deduplicates inbound commands by
messageIdfor 10 minutes via Redis (vcp:seen:{orgSlug}:{messageId}, atomicSET NX EX 600). The first delivery proceeds; duplicates inside the window are acked at the broker and silently dropped — no ACK is published back to the partner for a replay, so do not wait on one. If downstream processing throws, Voke releases the claim so a legitimate retry with the samemessageIdproceeds. Outside the 10-minute window the samemessageIdwill be reprocessed — design commands to be semantically idempotent (scheduleId,correlationId, stable command refs) rather than relying on the replay guard alone. See VCP message integrity for the full replay-guard contract. - Partners must deduplicate inbound events by
messageIdlocally. After a consumer reconnect, RabbitMQ may redeliver the most recently unacknowledged batch. A simple in-memory LRU set of the last NmessageIdvalues is sufficient for most cases.
Observability
Partners typically log every inbound ack and status event keyed by correlationId so the complete command round-trip is reconstructable from partner logs alone:
[correlationId: req-abc123] SEND command.site-setpoint messageId: msg-001
[correlationId: req-abc123] ACK QUEUED messageId: msg-001-ack
[correlationId: req-abc123] STATUS EXECUTING messageId: msg-001-status-1
[correlationId: req-abc123] STATUS COMPLETED messageId: msg-001-status-2On Voke's side, VcpCommandLog persists the same timeline — every command, ack, and status event is a row keyed by messageId and correlationId. When filing an incident, provide the messageId and Voke ops can find the corresponding VcpCommandLog rows within seconds.
Cross-links
- Reference / Error Codes — full
ApiErrorCodetable with HTTP status and descriptions - Commands — command types, routing keys, ack flow
- VCP message integrity — envelope structure and signing