Two Questions a Timeout Can Answer
A timeout can ask either of two questions. Has this taken too long? Or: has anything happened lately? They sound alike and they fail in opposite directions. A deadline on the whole operation punishes a slow but healthy link: a large upload over a hotel uplink is doing everything right and still runs out of time. A limit on silence punishes only a dead link: as long as bytes keep moving, the operation may take as long as it needs.
Delta's rule for moving bytes is the second one. Nothing moving for 30 seconds is the failure. Taking a long time is not. A 16 MiB chunk over a 2 Mbit/s uplink takes about a minute and is fine.
The Bug Hidden in sendall
The first version believed it already had that rule. Every connection set a 30 second socket timeout, and a socket timeout sounds like a stall timeout. It is not, for one call in particular. Since Python 3.5, the timeout on socket.sendall() is the maximum total duration to send all the data. It is no longer reset each time some data goes out. http.client hands a request body that is one bytes object to a single sendall. So a 16 MiB chunk had to leave within 30 seconds, which is about 4.5 Mbit/s per stream. Below that, a push failed on every run, while every byte was still flowing.
The review that found it wrote the failing test first (test_a_slow_uplink_does_not_fail_a_body_that_keeps_moving), marked as an expected failure, and then fixed the code under it.
The Fix: Bodies Leave in Pieces
A request body larger than 256 KiB is now passed to http.client as a generator of 256 KiB memoryview slices, with an explicit Content-Length. http.client sends each piece with its own sendall, so each piece gets its own 30 seconds. The body as a whole has no deadline. Three details make it work:
- No copies. A
memoryviewslice of the chunk's buffer is a window, not a new 256 KiB object. A bundle's parts (header lines and file bytes) are sent as they are, not joined into a second copy first. - Progress per piece. Each piece that leaves reports its size to the job, so a slow link shows bytes moving instead of a frozen counter that jumps every minute.
- Progress taken back. If an attempt fails and is tried again, the bytes it reported are subtracted first. The counter never claims bytes that did not arrive.
The Receiving Side Has the Same Rule
Reading is easier to get right, because each recv is its own blocking call, so each one gets the full timeout. Delta reads a chunk straight into one preallocated buffer, up to 1 MiB per call. Every read goes through one wrapper that turns the two ways it can fail into named causes:
| What happened | What the caller is told |
|---|---|
| no response header within 30 s | timeout: "first byte from the peer for GET … did not arrive within 30 s" |
| the body stopped for 30 s | timeout: "the peer stopped sending for 30 s at 1.2 of 16.0 MiB via lan" |
| the connection ended inside the body | unavailable: "closed the connection mid-body at 1.2 of 16.0 MiB" |
| sending stalled | timeout: "sending PUT … stalled for 30 s" |
The third row matters more than it looks. http.client's read(n) returns a short body without a word when the connection closes early. Only a read with no size raises IncompleteRead. Delta compares what arrived with Content-Length after every body. A short read is a cut and becomes an error, never a smaller file.
Where the Limits Sit
The stall limit is not the only clock. Connecting has its own short timeouts per route (2 s on Thunderbolt, 3 s on the LAN, 8 s on the tailnet), because a Mac that is off should be noticed quickly. Track 3 covers what happens to the callers after that. Hash requests may wait longer for their first byte (up to 900 s), because the source may have to read a large file before it can answer. Everything else that moves bytes lives under the one rule: no deadline on the work, and 30 seconds of patience for silence.
When the stall limit fires, the job does not give up at once either. Lesson 5 covers the retries that sit on top of it.