Errors and recovery
MTE Relay v5 answers relay faults with HTTP statuses 470 to 490, and keeps 500, 502, and 503 for true server faults, upstream failures, and state store outages. Each error carries a reason token, and each code has a registered client action. The v4 custom codes (559 to 570) are not used; the protocol registry keeps 559 to 569 retired so they are never reassigned.
Relay faults moved out of the 5xx range, where a CDN or proxy may replace the
response body with its own page. Cloudflare, Application Gateway, and Front
Door do the same to plain 4xx bodies such as a 403 or 429, which is why a
discovery refusal is 480, 488, or 490 and never one of those: an ERROR
frame with a relay status survives them. That is also why v5 repeats every
error in a response header. SocketX 2.x answers with the same codes.
How an error arrives
Every relay generated response carries the error twice:
- The body is an ERROR frame: a two byte code followed by JSON with
reason, an optionalmessage, and an optionalcause. - The
X-MTE-Relay-Errorheader repeats the code and reason, for exampleX-MTE-Relay-Error: 470 pair_not_found, in case a proxy replaces the body. A response toHEADcarries no body, so the header is its only copy.
The HTTP status equals the code. Error responses also carry
Cache-Control: no-store, no-transform, private, and Retry-After when the
client should wait. If a failure happens after the relay has started a framed
response, the response ends with an ERROR frame instead of a truncated body.
A v5 client treats any answer that is not a valid frame, whatever its status or content type, as a transport error and never as application data. A CDN error page, a WAF block page, or an ingress 404 lands there.
Codes
The HTTP relay column says whether the MRS and MAR relay sends the code in this release. The others are registered for the connection transports and gateways that share the protocol.
| Code | Reasons | Meaning | Client action | HTTP relay |
|---|---|---|---|---|
| 470 | pair_not_found | The relay has no state for this pair, while the client still has other pairs there. | Replace the pair. | yes |
| 471 | decoder_restore_failed | The relay could not restore the pair's decoder state. | Replace the pair. | yes |
| 472 | encoder_restore_failed | The relay could not restore the pair's encoder state. | Replace the pair. | yes |
| 473 | decode_failed | Decoding failed. | Replace the pair. | yes |
| 474 | encode_failed | Encoding failed. | Replace the pair. | yes |
| 475 | invalid_client_id | The token failed its signature check, which happens when the domain secret changed or a replica has a different secret, or the relay holds no pairs for this client, for example after a restart without Redis. | Full repair: authenticate again and pair again, at most once per 30 seconds. | yes |
| 475 | invalid_token | At pair or keepalive, the token is older than TOKEN_MAX_AGE_SECONDS. | Authenticate again. The relay answers an aged token with a new client id, so the client drops its pairs and pairs again. | yes |
| 475 | unknown_key | A preshared key was never issued or was revoked. | Stop and tell the operator. | no |
| 476 | pool_exhausted | No capacity. | Back off. | no |
| 477 | invalid_frame, eof_before_end | The frame broke a rule, was truncated, or the request deadline passed between frames. eof_before_end means the body ended without END. | invalid_frame: surface, the pair stays usable. eof_before_end: replace the pair. | yes |
| 478 | frame_version_unsupported | The client offered no frame version the relay accepts, sent a frame version 1 body, or sent HEAD /api/mte-relay, the v4 SDK's probe. | Surface. The message names the accepted versions and the minimum SDK versions; the texts are on Discovery. | yes |
| 479 | replay_rejected | The decoder rejected a replayed or out of order request. | Surface, the pair stays usable, and log a security event. | yes |
| 480 | no_domain_for_host | No DOMAIN_MAP entry matches the Host, at discovery or on any later request. | Surface. The message names the host the relay saw. | yes |
| 481 | frame_too_large, body_too_large, message_too_large | A frame over MAX_FRAME_BYTES, a body over MAX_REQUEST_BODY_MIB or MAX_CONTROL_BODY_MIB, or a reassembled WebSocket message over its bound. | Surface. On HTTP the pair stays usable. | yes |
| 482 | metadata_invalid | The request metadata JSON is malformed or over its bound. | Surface, the pair stays usable. | yes |
| 483 | header_mismatch | The encrypted method, version, or flags disagree with the plaintext envelope. | Surface, the pair stays usable, and log a security event. | yes |
| 484 | target_forbidden, port_out_of_range, authz_revoked | Connection transports only. | Reset the local socket. | no |
| 485 | upstream_capacity | An outbound domain ran out of pair capacity toward the next relay: for CLIENT_PAIR_ACQUIRE_WAIT_MS, more requests were in flight than CLIENT_MAX_PAIRS or than the next relay's MAX_PAIRS_PER_CLIENT, or the pool was still growing behind the next relay's pairing rate limit. See Limits and scaling. | Back off, honoring Retry-After. | yes |
| 486 | pair_busy | Reserved in the registry. The HTTP relay does not send it in this release. | Retry once, then replace the pair. | no |
| 487 | rekey_overdue | Connection transports only. | Reconnect with a new OPEN. | no |
| 488 | connection_limit, rate_limited | Too many requests. The HTTP relay sends rate_limited on discovery, pair, and keepalive, with the message too many requests; back off with jitter. Discovery allows 600 requests per minute per source with a burst of 600. | Back off with jitter, honor Retry-After, and keep the pair. | yes, rate_limited |
| 489 | target_reset | Connection transports only. | Reset the local socket. | no |
| 490 | invalid_request, pair_exists, pair_limit, profile_mismatch | Validation failed on a JSON endpoint or an outbound request, or the Host header does not parse (invalid_request, at discovery or on any later request). | pair_limit: drop unused pairs through keepalive and retry once, then stop growing. profile_mismatch: fatal setup error. The rest are client bugs. | yes |
| 500 | internal, configuration | A server fault, or a configuration problem such as a domain with no secret. | Surface. | yes |
| 502 | upstream_connect_failed, upstream_response_failed | upstream_connect_failed: the upstream could not be reached, or sent no response headers within HTTP_CLIENT_TIMEOUT_SECONDS. upstream_response_failed: the upstream ran the request and the response then failed. | Surface. upstream_response_failed means the origin ran the request. upstream_connect_failed usually means it did not, but after a header timeout the origin may still have run it. | yes |
| 503 | state_store_unavailable, state_store_write_failed | state_store_unavailable: at pair or keepalive the state store could not be read or written, and nothing executed. A state read that fails on the data path is answered as a missing pair, 470. state_store_write_failed: the relay could not save the pair's state; for a streamed body the origin has already run the request. | state_store_unavailable: retry after Retry-After, the pair stays usable. state_store_write_failed: replace the pair and do not retry. | yes |
A receiver accepts an unknown reason under a known code and applies that code's action, so a newer relay does not break an older client.
Other statuses you can see
Some answers come from plain HTTP handling rather than the relay error model:
404for a request to an inbound domain that is neither a frame (POSTwithContent-Type: application/octet-stream) nor a pass through route.405for a wrong method on a relay endpoint, for example anything butGETorHEADon/api/mte-relay. AHEADthere is how a v4 client starts, and the relay answers it478 frame_version_unsupportedwithout issuing a token. AnOPTIONSrequest to a host inDOMAIN_MAPgets204instead, unlesscors_preflight_passthroughis on. The method is checked before theHost, so aPOSTor anOPTIONSto a host thatDOMAIN_MAPdoes not match gets405, not480.502 Upstream request failedas plain text when a pass through route cannot reach its upstream.
A successful framed response always travels as HTTP 200. The origin's real
status travels in the RESPONSE frame, as a plain status field that the
encrypted metadata repeats, so a load balancer log shows 200 for relayed
requests and 470 to 503 for relay errors.
Outbound domain errors
On an outbound domain the caller speaks
plain HTTP, so errors arrive as JSON instead of an ERROR frame, with the same
X-MTE-Relay-Error header. The exception is a host that matches no
DOMAIN_MAP key or does not parse: the relay answers it before it knows the
domain is outbound, so that 480 or 490 arrives as a binary ERROR frame.
Read the header in that case.
{
"code": 502,
"reason": "upstream_connect_failed",
"message": "the next relay answered an error",
"cause": { "code": 470, "reason": "pair_not_found", "hop": 1 }
}
cause names the error the next relay returned and how many hops away it
happened, with the same lowercase members as the top level. The outbound relay
turns a next relay error into 502: upstream_response_failed when the next relay reports
that its origin ran the request or answers 500, and upstream_connect_failed
otherwise. Pair capacity trouble is 485 upstream_capacity with Retry-After. Before answering, the relay retries a
request on a fresh pair, up to four times, when the next relay rejected it with
a pair, token, or state store error before its origin ran. Other errors, such
as 481, 482, 488, and 490, are answered at once.
Operations checklist
- Alert on 470 to 490 and on the
X-MTE-Relay-Errorheader, not on 559 to 570. - Keep 500, 502, and 503 as your true fault signals.
- Treat a sustained rise in
475 invalid_client_idacross replicas as a sign that replicas do not shareclient_id_secret, or that pair state is not shared through Redis. - Log
X-MTE-Relay-Request-Id. The relay sets it on every response and sends it upstream under the same name, and the browser engine exposes it asrequestIdon its errors, so one id ties a client error to relay and origin logs.