We sync product catalogues out of Tally, the accounting software a lot of Indian distributors run their business on. A small desktop agent on the client's PC exposes Tally's XML API through a Cloudflare tunnel, and a worker on our side asks for every stock item in one request.
For most companies that's fine. For big ones I'd already been burned once: the reply came back so large it blew past what we'd parse in one go. So I built a fallback. First ask Tally for just the item ids, then fetch the items 250 at a time, filtered by id range.
The fallback ran when the single request failed with a parse-size error. That felt like the right trigger, because "too big to read" is exactly the problem batching solves.
The company that never answered
This morning a client's catalogue sync failed with this:
Request failed with status code 524
"The origin web server did not return a complete response within the
120-second Proxy Read Timeout window."
No size error, no partial body. Nothing came back at all, and the batched path that would have worked never ran.
Why size never showed up
Tally doesn't stream an export. It builds the whole response in memory and only then starts writing. So on a big enough company, the first byte shows up after the request has already sat past Cloudflare's 120-second read timeout. Cloudflare gives up, returns a 524, and our code sees an HTTP error, not "this was too big".
one request, two limitssmall company build ██ send ██ fine large company build ██████ send ████████ too big → batched fallback huge company build ████████████████████| 120s: 524, fallback never runs └── Cloudflare stops waiting here
Size and time are the same problem showing up as two different errors. A large export is slow to build before it's large to parse, and past a point it never gets as far as being large. I had only handled the second error.
The fix
Two small changes:
- Treat "too slow" like "too big". A 502, 504 or 524, or a client-side timeout, on the single request now takes the same batched path as a parse-size error. Each batch is 250 items, which a large company builds well inside the limit.
- Don't pay the 120 seconds twice. Once a company has timed out, it goes into an in-memory set and later syncs skip straight to batches. It's in memory on purpose: a restart costs one more slow attempt and nothing else, and I don't need a table for it.
// simplified from the master reads (stock items, ledgers)
const isTooSlow = (err) =>
[502, 504, 524].includes(err.response?.status) ||
['ECONNABORTED', 'ETIMEDOUT'].includes(err.code);
if (batchOnly.has(companyKey)) return fetchMastersBatched();
try {
return await fetchMastersSingleShot();
} catch (err) {
if (!isTooBig(err) && !isTooSlow(err)) throw err;
if (isTooSlow(err)) batchOnly.add(companyKey);
return fetchMastersBatched();
}
I don't have an after number yet. The client's PC has been offline since the fix shipped, so the batched path hasn't run on that company. I'll add the batch count and total time when it does.
What I'd tell myself last month
When you write a fallback for "the response is too big", ask how that failure actually shows up on the wire. With anything that builds its reply before sending, whether it's a report generator, a big SQL export or an ERP, "too big" usually shows up first as "too slow".
- The error you get depends on which limit the reply hits first.
- Your fallback has to catch both, or it only works for the middle-sized case.
- Remember who timed out, so the slow attempt is paid once, not on every sync.