Stream Read Budget Exceeded
URI: https://docs.ankatech.co/errors/stream-read-budget-exceeded
HTTP Status: 408 Request Timeout
When you see this
The total elapsed time spent consuming the request body of a streaming operation exceeded the budget this deployment allows for one streaming upload. The budget is the operator-configured crypto.stream.max-read-duration-ms (default 900000 ms, 15 minutes) and it applies to every streaming endpoint.
The budget bounds total duration, not the gap between two consecutive chunks. That distinction is about which clock is measured, not about who is exempt: a slow link is precisely what this budget refuses, because a client trickling bytes indefinitely never trips a gap bound of any plausible size and can hold a connection open for as long as it likes. What the total-duration axis changes is that a long pause between two chunks is not by itself a refusal — the request is measured on how long it takes in total, however that time is spent.
Common Causes
- A payload large enough that transferring it takes longer than the configured budget.
- A sustained low-bandwidth connection stretching an otherwise ordinary upload past the deadline.
- A deployment whose budget is configured well below its default for a constrained environment.
Response Example
{
"type": "https://docs.ankatech.co/errors/stream-read-budget-exceeded",
"title": "Request Body Read Timeout",
"status": 408,
"detail": "The request body was not fully received within the 900000 ms this deployment allows for one streaming upload. Resend it over a faster connection, or ask an operator to raise the configured streaming read budget.",
"instance": "/api/v3/crypto/stream/encrypt",
"correlationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"timestamp": 1745120403
}
Content-Type: application/problem+json — the response follows RFC 7807 Problem Details. The detail names the configured budget in milliseconds, so a caller can tell a budget they could meet from one they cannot. Quote the correlationId when contacting support: it is what joins this response to the server-side trace of the same aborted read.
The refusal also closes the connection on the server's own hop, and carries Connection: close to say so. Without it the server would go on draining the rest of your request body after the refusal had been written, so the refusal would cost neither side anything and the budget would bound nothing; RFC 9110 §15.5.9 recommends the header on a 408 for the same reason.
You will not normally see that header, and that is expected. Connection is hop-by-hop: the edge proxy in front of ANKASecure does not forward it, so on the standard routed path the close governs the edge-to-server hop only and your own connection to the edge is unaffected. It is also a forbidden header in HTTP/2 (RFC 9113 §8.2.2), so an HTTP/2 client cannot receive it. Only a client that reaches the service directly, over HTTP/1.1 with no proxy in between, receives the header itself; such a client should open a fresh connection for the retry, which most HTTP clients do transparently.
What the header describes — the server stopping its read of your body — is what makes the budget bound anything at all, and that behaviour is not conditional on the header being delivered. How it surfaces to an HTTP/2 client has not been measured, so treat the HTTP/1.1 and HTTP/2 experiences as equivalent in outcome but not verified to be identical in observable signalling.
How to Resolve
- Retry with a smaller payload, so the transfer completes within the configured budget.
- Retry over a faster or less contended connection.
- Ask the platform operator to raise
crypto.stream.max-read-duration-ms(accepted range: 60 000 – 3 600 000 ms).
This is a client-caused condition, not a server fault — which is why the status is 4xx. Nothing is committed on the server side: the operation is not partially applied, so a retry is safe.
Limitation: the refusal is only representable before the response is committed
This 408 reaches you only when the budget expires before the response has begun. Once the response has been committed — which duplex streaming makes the ordinary case, since the payload is consumed while the reply is already being written — no status can be set, and the observable outcome is a truncated response stream with no problem document at all.
That truncation is the designed fail-closed behaviour, not corruption of your data: splicing a problem document into a committed stream would corrupt the payload instead of refusing it. Nothing is committed on the server side in that case either.
Treat a truncated streaming response as a possible read-budget expiry rather than a damaged payload, and quote your correlation identifier when contacting support: an operator can search the server-side trace for that identifier.
A budget expiry always leaves a trace at the operator's default log level, so a truncation caused by this condition is the case support can look up directly. A truncation caused by the connection dropping may not be: those traces are volume-bounded and a repeat within a short window is recorded at a level operators do not run by default, so support may need to raise it before your abort becomes visible. Quote the correlation identifier either way — it is what makes the lookup possible once the level is raised.
For full schema definitions, examples, and interactive testing, see the Developer Hub Reference.