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 9457 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 stops the server reading the rest of your request body on its own hop; without that, the budget would bound nothing. How it stops depends on the protocol of that hop. Over HTTP/1.1 the server discards at most a bounded remainder of the body (about 2 MiB) and then closes the connection, and the response carries Connection: close to say so, as RFC 9110 §15.5.9 recommends for a 408. Over HTTP/2, which is how the environment NGINX reaches the service on the streaming routes, the server ends the response and then resets the request stream, so it accepts at most one stream flow-control window (65,535 bytes) of further body; the connection stays open for other requests.
You will not normally see a Connection 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 your own connection to the edge is unaffected. HTTP/2 forbids the header (RFC 9113 §8.2.2), so the service does not send it on an HTTP/2 response. Only a client that reaches the service directly, over HTTP/1.1 with no proxy in between, receives it; such a client should open a fresh connection for the retry, which most HTTP clients do transparently.
The server stopping its read of your body is what makes the budget bound anything at all, and it does not depend on any header being delivered. Whichever protocol your client speaks, the outcome is the same: the refusal is the response you receive, and the rest of the body is not processed.
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 is committed, that is while less than one response buffer of output has been written. Writing and flushing output does not by itself commit the response: it commits when its buffer fills. So a refusal that arrives while the output is still buffered — including a /stream/reencrypt refusal on the source leg after the target header has been written — is this 408. Once more than one buffer of output has been written the response is committed, no status can be set, and the observable outcome is a truncated response stream with no problem document at all. On a large payload, whose output overflows the buffer early in the transfer, a budget that expires late in the transfer falls on that side.
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. Whether your client notices the cut depends on the operation: decrypt, reencrypt, resign and decrypt-verify end with a verdict whose absence is detectable, while a clean end of an encrypt response cannot be told apart from completion, so compare the output against what you sent before relying on it.
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.