Notes on streaming large API responses
simonwillison.net
simonwillison.net
First, it’s much simpler to define meaningful latency SLOs. If requests take roughly the same time, I can say that requests (or even requests “of this type”) should complete with p99 under 300ms. If they’re very variable, it’s harder to know when the service is degraded. (You can use time-to-first-byte, which is usually more uniform, but you can miss degradation that affects the data stream. Then it’s tempting to look at throughput, but that’s impacted by client speed, which you usually want to ignore here.)
The other is that bulk requests can make it much cheaper to DoS the service. If all requests take about the same amount of work on the server, then forcing the server to do work requires proportional client resources to make the requests. If you can make one small request that causes the server to go off and do a bunch of work while the client does almost no work, that’s a cheap way to DoS a server.
Either way, don't rely on a continuous long running connection. You need to be able to restart or retry without starting over. That's table stakes, and it makes a resumable pure streaming API look a lot like a paginated one. The hard bit of pagination is efficient resuming, not limiting to a page size.
Efficient pagination means having enough information on each subsequent request to pick up reliably and efficiently from where you left off, e.g. using a sequentially allocated indexed ID on the root table (to which other data is joined).
Sometimes the database needs convincing to use the right index of you have joins, as DBs are generally tuned for extracting a handful of rows at a time. I've had more reliable success (over a multi-year maintenance timeline) by doing a single paginated query over a root table and doing separate queries to get the joined data, using a page of root IDs at a time for the joins. Doing the join in code also gives leeway for parallelization of the data load across cores (MySQL queries are single threaded), or even across replicas.
If I can send a bunch of small chunks to the client and the first few can already be processed, it doesn't matter that I had to load all of the chunks at once from my database.
I built a streaming statistics server that ingests data from edge nodes and aggregates the data into a memory database.
The server has an API interface and one of the endpoints will stream all the data in either a compact or pretty JSON format.
example of the datasets: millions of unique unordered keys with each key containing a list of time series data.
The memory overhead to stream Gigabytes of data is limited to a couple of MB (buffers for encoding and writing).
I ended up having to write my own JSON encoder to achieve the small memory overhead, JSON formatting, and write performance.
This has saved both my very limited F1 server from sad OOM exceptions and my iOS vm'd client from crashing while processing too much text
I do the same for uploads
There is no memcache in node.js F1 but what I do is I use the GCS container itself as stream memory instead of the usual node.js memory multer (yes, this is possible!)
This only adds a few ms to the read op which is entirely fine
Times as many instances as you have. So 28 hrs = 1 instance running all day + an extra 4 hours of a second instance
It auto scales
You can generate a zip stream and then stream it over http.
Currently for the paging endpoints some of the clients are opening hundreds of connections to slurp tens of gigabytes of data as fast as possible.
Switching these to streaming directly from database improved the performance of a single request about 100 times as clients can use 10 times less connections and still fetch whatever they need 10 times faster than before.
As to drawbacks, the biggest one is additional effort needed to educate people on proper streaming, design of APIs, implementation and correct use of Reactor (ReactiveX implementation for Java).
There are couple of technical drawbacks.
Resuming a streaming response is a little bit more involved. My ideal solution, whenever I can, is to produce the report in order of some database index and if the call fails, have the client submit the ID of last received response when retrying the call.
Achieving optimum performance requires a bit of thought and planning. A lot of internal processes require individual things to be batched together (we try to never direct calls to database for single items, opting to process everything in batches of at least 100 or 1000).
When the endpoint has functionality that needs to be executed multiple times concurrently to get needed performance, sometimes an internal function also has its own concurrency because it needs to run something else multiple times in parallel to achieve its target internally. This can quickly cause the developer to loose track of how much stuff is running concurrently. The solution to this is that rather than components returning Flux<Something> typically they return Flux<Flux<Something>>. This can be thought as function saying "my result can be fetched from a collection of multiple substreams of results, and you can decide however many you are comfortable running concurrently". This allows to give the calling function the control of concurrency at the cost of little bit of mental yoga.
I think of this problem following way: you already have to design your system to be able to handle network faults and node crashes. So just kill the node and rely on existing solution to handle the error.
This also gives the opportunity to test your fault handling logic.
Error messaging is a little bit more complicated.
At first sight, in normal request-response you get either successful response or an error.
But if you look a little bit closer, in fact you get 200 response header followed by response data, which may itself fail to be transmitted. So technically you already need to be able to resolve an error that happens after you have received 200 header.
In case of streaming response the difference is not in how the response is being sent to the client but how the client is processing it.
The client has option to wait for entire response (header followed by entire stream of data) and only start processing the response after entire response has been received. This is no different from a normal, non-streamed request response.
The client also has option to start processing the response while it is being received. The response does not need to be streamed from the server nor it needs any kind of streaming protocol. I have been stream-processing XML responses two decades ago with no special streaming support.
One solution I use is that is that the client architecture treats this data as speculative meaning that an error can undo entire batch received.
Another solution is that the design is such that the data makes sense even partially received, the processing of response is idempotent and the client will be asking, ad infinitum, to receive the rest of the response until the call succeeds. This of course has its own reliability problems (what if the error is permanent?)
It‘s quite refreshing to develop with reactive spring.
They’d rather re-invent the wheel every 5 minutes than use any out of the box java solution.
Modern browsers can read the response of a network request in a streaming fashion
Some of the calls return millions of objects. In such cases we opt to return very small amount of information (ideally just ID) and have the client batch responses and direct batches of IDs to another endpoint to fetch full documents, with response also streamed using ndjson.
But there is nothing wrong with returning entire 20GB of data except that the client gets no other option than wait for entire set being returned through a single connection.
> Some export formats are friendlier for streaming than others. CSV and TSV are pretty easy to stream, as is newline-delimited JSON.
It does have the same minor disadvantage as CSV in that it can be truncated (by an error or a dropped connection) in a way that can look like a successful download.
Here's my test in Datasette for the streaming CSV response - I already have a table with 1001 items in it setup for another test: https://github.com/simonw/datasette/blob/0.57.1/tests/test_c...
This can be quite annoying if you have e.g. a separate process polling for an S3 object. The object can appear in a HEAD and then return a 404 on the subsequent GET.
def is_file_uploaded(file_name, request_id):
for i in range(S3_UPLOAD_ATTEMPTS_COUNT):
try:
s3.Object(BUCKET, file_name).load()
return True
except botocore.exceptions.ClientError as e:
logger.warning('File still not uploaded to S3.
Check - {}/{} (request_id - {})'.format(
i+1, S3_UPLOAD_ATTEMPTS_COUNT, request_id
))
return FalseYou might also have better luck using the region-specific S3 endpoint rather than the global domain name.
And no - haven't contacted support so far. Will assign someone from my team to get in touch with them.
Maybe a 202 Accepted code makes more sense