Relay · Limits
Limits, errors, and backpressure
Relay rejects excess work before executing the requested conversation action. Clients should wait for the returned retry window, preserve message nonces across uncertain sends, and treat authorization or validation failures as state changes rather than transport failures.
Current default budgets
These are the current self-hosted defaults. A deployment operator can replace them per tenant.
| Budget | Scope | Default |
|---|---|---|
| Message writes | User within tenant | 10 per second |
| Message writes | Entire tenant | 100 per second |
| WebSocket frames | User within channel | 30 per second |
| WebSocket joins | User, separate join scope | 30 per second |
| Attachment presign | User within tenant | 30 per minute |
| Attachment presign | Entire tenant | 600 per minute |
| Attachment download URL | User within tenant | 120 per minute |
| Search queries | User within tenant | 60 per minute |
| Search queries | Entire tenant | 600 per minute |
| Host credential sync | Tenant | 20 per second |
| Host credential sync | Source IP | 60 per second |
| Outbound host webhooks | Tenant and destination URL | 50 per second |
Message write budgets apply to REST message create, edit, retract, pin, unpin, and reaction mutations. WebSocket message send, edit, retract, and reaction events use the same user and tenant budgets, with the additional per-channel frame budget. Pin and unpin events use the frame budget only.
Channel creation, membership changes, channel close, metadata updates, notification mute, and ordinary reads do not currently share a general request budget. Search and attachment operations have their own limits.
REST rate-limit response
A limited message, search, upload, or download request returns 429 Too Many Requests:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/json
{
"error": "rate_limited"
} Retry-After is a whole number of seconds rounded up from the remaining window. Wait at least that long before retrying.
Host credential sync also returns 429, but it currently uses an errors array and does not include Retry-After. This is a response consistency gap, not a separate retry guarantee.
WebSocket rate-limit response
An in-channel event returns its delay in milliseconds:
{
"error": "rate_limited",
"retry_after_ms": 847
} This shape applies to message writes and frame-limited events such as cursor updates, pins, thread reads, and typing indicators.
A rate-limited channel join returns only:
{
"reason": "rate_limited"
} The join response has no retry value. Back off with jitter before joining again. Do not reconnect in a tight loop.
Common HTTP status codes
| Status | Meaning | Client action |
|---|---|---|
400 | Malformed admin or operator request | Correct the request shape |
401 | Missing, expired, revoked, or invalid credentials | Refresh or replace credentials |
403 | The actor lacks membership or permission | Revalidate host authorization before retrying |
404 | The resource is absent or hidden by tenant and resource scope | Do not assume another tenant contains it |
409 | The resource lifecycle conflicts with the operation | Refresh current state before deciding what to do |
410 | An attachment is tombstoned | Remove the stale attachment reference |
422 | Domain validation failed | Correct the payload or product state |
429 | A rate budget was exhausted | Wait for the retry window |
500 | Relay could not complete the request | Retry only when the operation has an idempotency strategy |
Most conversation endpoints return errors as an array:
{
"errors": ["forbidden"]
} Rate-limit, search, and attachment controllers use a singular error field. Clients must currently support both envelopes. A unified error code and response schema are not implemented.
Retry by operation
For message creation, keep the original nonce after a timeout, disconnect, 429, or retryable 5xx. If the first attempt committed, Relay returns the existing message instead of creating another one.
For reads, retry after the provided window. For edits, reactions, membership changes, channel lifecycle actions, and attachment operations, first determine whether the previous attempt committed. These operations do not all expose a client idempotency key.
Do not retry 401, 403, 404, 409, or 422 unchanged. A credential, permission, resource, lifecycle, or payload condition must change first.
Operator overrides
An operator can replace a tenant’s stored overrides through the admin API:
PUT /api/admin/tenants/{tenant_id}/rate_limits
X-Admin-Token: <admin-token>
Content-Type: application/json
{
"rate_limits": {
"user_message": {
"scale_ms": 1000,
"limit": 20
},
"tenant_message": {
"scale_ms": 1000,
"limit": 200
}
}
} The request replaces the complete override map. Omitted budgets fall back to deployment defaults. Sending an empty map clears every override. Values must contain positive integer scale_ms and limit fields.
This endpoint requires the deployment admin token. It is not a tenant self-service control plane.
Backpressure boundary
Rate-limited synchronous REST and WebSocket operations are rejected before the conversation action runs. Relay does not queue those client requests for later execution.
Post-commit WebSocket fan-out, host webhook delivery, search indexing, and adapter egress use durable internal handoffs. A host webhook that exhausts its outbound budget is delayed rather than dropped immediately. This protects request latency, but it does not promise a maximum delivery delay.
Message order still comes from channel-local seq, not worker completion or webhook arrival order.
Product boundary
The current limiter uses node-local ETS counters. In a multi-node deployment, each Relay node enforces its own counter, so the table above is not a cluster-wide aggregate ceiling.
Cluster-wide rate accounting, complete coverage for channel and membership operations, consistent error envelopes, standard limit and remaining headers, and a retry value on WebSocket join remain product work.
Treat the defaults as current deployment behavior, not a managed-service quota or SLA.