Repository navigation
feat(client): retry only failures another try can fix, with jittered backoff - #62
gonzalo-zenrows wants to merge 9 commits into
Conversation
…backoff A cold Extract request can briefly answer 503 with "retry in a minute, not charged", and many clients retrying on the same fixed schedule hit the API again in lockstep. The client now retries by default and spreads its retries out. - retries now defaults to 2 (was 0); retries=0 turns retries off. - Retried: 429, 502, 503, 504 (none of them is charged) and a failure to connect. No longer retried: 500 and 422. Never retried: a read timeout, because the server may still finish, and charge, the request. - Waits honour Retry-After, capped at retry_max_wait (default 30 s), or else a random 0.25 s to min(retry_max_wait, 2 ** attempt) s. - retry_budget (default 90 s) stops retrying when the next wait would end that long after the call's first try. - The separate 422 retry loop in _worker is gone: one retry layer. - urllib3 >= 1.26 is now declared, for Retry(allowed_methods=...).
|
Same retry policy across all clients (429/502/503/504 and pre-response connection errors; 2 retries by default; full jitter; Retry-After capped at 30 s; 90 s total budget; timeouts never retried):
|
The first retry change retried every POST and PUT. A POST whose body was a generator then went out a second time with an empty body, and a POST that the target had already processed could run twice. The same change also stopped retrying 422 and 500, which callers relied on. 1. A GET is always eligible. A POST or PUT is retried only with an Idempotency-Key header and a body the client can send again. A generator, an iterator or a stream that cannot seek is never retried. 2. A 500 is retried again. A 422 other than no_data_extracted is retried again in _worker, because urllib3 cannot read the body. It uses the same wait, the same retry count and the same budget clock as the urllib3 retries, so a 422 and a 5xx in one call share both. 3. Retry-After counts only as whole seconds, clamped to 0.25 s and 30 s. The 30 s cap no longer follows retry_max_wait. An HTTP date or a fraction falls back to the jittered wait, which never drops below 0.25 s. 4. The Autoparse fallback of extract(mode="auto") starts no retry after the budget of the first request, sync and async. 5. retries, retry_max_wait and retry_budget are checked when the client is created. An invalid value raises ValueError instead of failing at the first retry. 6. The README documents what is retried, the waits, the budget and the changed default, and its examples use the default retries.
jonathan-zenrows
left a comment
There was a problem hiding this comment.
Two bugs, plus one thing to decide before merging.
1. The 422 retry sends an empty body when data is a file object
_can_send_again accepts a seekable file, and the 5xx retries rewind it (urllib3 handles body_pos). The 422 loop in _worker (client.py:642-647) calls send() again with the same data, which is now at EOF. The second request goes out with an empty body and the caller gets a 200.
Repro: run a local server that answers 422 and then 200, and log each request body:
cl = ZenRowsClient("k", base_url=base)
cl.post("http://t", headers={"Idempotency-Key": "a"}, data=io.BytesIO(b"PAYLOAD"))
# bodies seen by the server: [b'PAYLOAD', b''] -> status 200
# same thing with 503 then 200: [b'PAYLOAD', b'PAYLOAD']Fix: save data.tell() before the first send and seek() back to it before each 422 retry. Or don't retry 422 for anything that isn't str, bytes or dict. The file-body test in test_retries.py:398-416 only runs with 503; add 422 to its parameters.
2. A long Retry-After raises
int(value) in _retry_after_seconds (client.py:74) raises ValueError on 3.11+ when the value has more than 4300 digits. The error reaches the caller unwrapped. Repro: a 429 with Retry-After: "9" * 5000. Fix: if len(value) > 2, return the cap without parsing; anything past 2 digits is above 30 s anyway.
3. Release note
The default goes from 0 to 2 retries, PUT now needs an Idempotency-Key, GET read errors are no longer retried, and retries=2.5 now raises. With feat(client): and skip-changelog: true, this ships as a plain minor and the title is the only note. It needs ! or a BREAKING CHANGE: footer, or at least a written note on the release.
Also: this conflicts with main (1.11.0 was released this morning), so it needs a rebase.
Rest of the spec checked and fine. The suite passes locally (393).
The API does not deduplicate Fetch or Extract requests by Idempotency-Key. It forwards that header to the target site. A retried POST or PUT with a key could therefore run twice, so the key proved nothing. GET, HEAD and OPTIONS are now the only retried methods. POST and PUT go out once, whatever the headers, the status or the failure, including a refused connection. Every method also keeps a body check. A file object, a generator, an iterator or another stream is never sent again, because the first try reads it. The 422 loop checks the data it builds the request from, and the urllib3 retry checks the prepared body. Before this, a POST with a key and an io.BytesIO body sent an empty body on its 422 retry, and the server answered 200 to it. The Idempotency-Key rule, the seek-and-rewind check and the tests for keyed retries are gone. The README now says POST and PUT are never retried, and that a caller who needs it writes that retry.
The 422 retry builds the request again from its arguments. A file passed as files is read by the first try, so the retry sent an empty file part and could return a 200 for a request that never carried the file. The 422 retry now skips any request with files. A 503 retry is unchanged: it sends the body that requests already built, file included.
With original_status set, the API answers with the target site's own status. The client cannot tell that status from one the API sent itself, so a 503 from the target was retried as if the API were busy. A status listed in allowed_status_codes is the same: the API passes the target's answer through. The retry adapter now reads both parameters from each request's query and stores the result on the call's retry state, next to the existing per-request flag. The urllib3 retry and the 422 loop both check it. With original_status true, no status is retried. A status listed in allowed_status_codes is not retried. A failure to connect is not a status, so it is still retried. The README lists this case under the statuses the client never retries.
The client parsed a whole-digits Retry-After with int(). Python refuses to convert a string of more than 4300 digits, so a 429 or 503 with such a header raised ValueError out of the retry code to the caller. Any value of three or more digits, after leading zeros, is already above the 30 s cap. The client now returns the cap for it without parsing. A value such as 0005 still waits 5 s, and 000 still waits 0.25 s.
Why
A request that fails in a way another try can fix should wait and try again. It should not fail at once, and it should not retry on a fixed schedule. For example, a cold Extract request can briefly answer
503with "retry in a minute, not charged". TodayZenRowsClientretries nothing by default. When a caller turns retries on, every client waits the same fixed time, so many clients retry together.What this changes
ZenRowsClientnow retries a failed request up to 2 times by default. It retries only failures that another try can fix, and only requests that are safe to send again.The client retries these failures:
429,500,502,503or504response.422response, except Extract'sno_data_extracted. That one is a final answer.The client never retries:
400,401,402,403or404.original_statusto true. The API then returns the target site's own status, and the client cannot tell it from the API's.allowed_status_codes. The API passes that status through from the target site, so another try cannot change it.The client reads both parameters from each request's query string. That covers
True,"true",1and"1"fororiginal_status, in any case. Forallowed_status_codesit takes a comma-separated string or a list. Both the urllib3 retry and the422retry check them. A failure to connect is not a status, so the client still retries it.The client retries these requests:
GETrequest whose body is empty, a string, bytes, or a dict the client encodes.POSTorPUT. It sends each one once, whatever its headers, its body, the status or the failure. That includes a refused connection.Idempotency-Keyheader changes nothing. The API does not deduplicate Fetch or Extract requests by that key, so a second try could run the request twice.POSTwrites that retry in their own code.This also fixes a bug in the
422retry. Before this fix, aPOSTwith anIdempotency-Keyand anio.BytesIObody got a422. The retry then sent the body again, empty, and the server answered200. Now the client sends the file body once and returns the422as it came. The422loop and the urllib3 retry each check the body they would send again.Before each retry, the client waits:
Retry-Afterthe API sent, when it is a whole number of seconds. The client clamps it to between 0.25 s and 30 s. The 30 s cap is fixed and does not followretry_max_wait.Retry-Afterof any length never raises. The client gives a value above 99 the 30 s cap and does not parse it.max(0.25, min(retry_max_wait, 2 ** attempt))seconds. Hereattemptis 0 for the first retry. An HTTP date, a fraction or any otherRetry-Aftervalue also gets this random wait.No retry starts after 90 s. The client does not start a retry when the time since the first try plus the next wait would pass the budget. The budget does not cut a running try short, so a call can run past it by one try.
A
422retry runs in_worker, because urllib3 cannot read the response body. It uses the same wait, the same retry count and the same budget clock as the urllib3 retries. A422and a5xxin one call therefore share one count and one budget. After the last try, the client returns the final response as it came.prepare_domain()andprepared_domain_status()never retry. Their429and503are answers for the caller to read, so the client sends these calls once.The client checks its options when you create it.
retriesmust be an integer of 0 or more.retry_max_waitandretry_budgetmust be finite numbers above 0. Anything else raisesValueError.A call cannot be cancelled. A wait between tries runs to its end, also for the
*_asyncmethods, which run the call on the client's thread pool. The README says so.retries0(off)2retry_max_wait(new, keyword-only)30.0seconds: the cap on the random waitretry_budget(new, keyword-only)90.0seconds: no retry starts after thisTo keep the old behaviour of no retries, pass
retries=0.Behaviour change
Callers who upgrade will see these differences:
POSTorPUT. Onmain, a client withretriesset retried aPUTthat got a503. ThatPUTis no longer retried.main, such a client also retried aPOSTorPUTafter a422or a refused connection. It no longer does.GETafter a read timeout or a connection that closes after the request was sent. Onmain, a client withretriesset retried both.422for a request that uploadsfiles: the second try would send the file part empty.Retry-After, and no retry starts after 90 s.ValueErrorwhen you create the client. Examples areretries=2.5, whichmainaccepted, andretry_max_wait=-1.original_statusgets no status retry. A status listed inallowed_status_codesis not retried. Onmain, a client withretriesset retried both.The README has a "Retries" section that names the changed default and says that
POSTandPUTare never retried.Tests
tests/test_retries.pyruns the client against a local HTTP server, so each test drives urllib3's real retry path. It covers:Retry-Afterand jitter vector of the shared retry policy, as parametrised tests.429,500,502,503,504, a422other thanno_data_extracted, and a refused connection.400,401,402,403,404,422 no_data_extractedor413withRetry-After. No retry on a read timeout. No retry on a connection that closes after the send (GET,POSTandPUT).POSTorPUTsent once, with and without anIdempotency-Key, for a503,429,422,500and a refused connection. The asyncpost_asyncandput_asyncare sent once too.io.BytesIOrepro: aPOSTwith a key and a file body gets a422or a503. The client sends the body once and returns that status.GETwithfilesgets a422: the client sends the file once and returns the422. On a503it sends the file in both tries.GETwith a file, generator or iterator body sent once on a503or422. AGETwith a string, bytes or dict body sent again with the same body.422and a5xxin the same call.fetch_async, which runs on the client's thread pool.extract()returning a403in one request, with no retry.prepare_domain()andprepared_domain_status()sending a429or503once, sync and async, with no wait.original_statusset toTrue,"true","TRUE",1or"1"that gets a429,500,502,503,504or422. The client sends it once.original_statusabsent,False,"false",0,"0"or empty that gets a503or422. The client still retries it.allowed_status_codesthat lists503gets a503. The client sends it once, for a string, a spaced string or a list. A502or422not in the list is still retried.422listed inallowed_status_codes, andextract()withoriginal_status, each sent once. This drives the422loop.original_statusset: the client still retries it.original_statusdoes not stop the next call's retries.429with a 5000-digitRetry-After. The client waits 30 s once and returns the next response.Results on this branch:
uv lock --checkandmake check: passed.make test: 498 passed on urllib3 2.8.0 with Python 3.10.POSTandPUTchange one at a time. Each of these eight mutations made a test fail:422loop only.422loop.Idempotency-Keyrule.422loop.main. Each of these four mutations made a test fail:prepared_domain_status()through the retry session.403inextract().422loop and urllib3 separate retry state.422loop.allowed_status_codeson commas.allowed_status_codeskey.allowed_status_codes.original_status.original_statuscase-sensitively.1as true.original_statusvalue as true.original_statusalso stop a retry after a refused connection.Retry-Afterfix. Each of these two mutations made a test fail:int()again.Not covered
POSTorPUT. If the API starts to deduplicate byIdempotency-Key, a new decision can add that retry back.allowed_status_codestokens that are not whole numbers are ignored. The client does not read ranges such as4xx.feat, so the version bump is a minor one.Release note
Paste this text into the release PR body when it opens, and check the version number.
Retries are now on by default.
ZenRowsClientretries a failed request up to 2 times. Before, the default was 0. Passretries=0to keep the old behaviour.GETthat gets a429,502,503,504,500, or a422other thanno_data_extracted. It also retries a connection that failed before the request left: refused, name not found, connect timeout.POSTorPUT, a read timeout, a connection that closed after the request was sent, or a status that comes from the target site (whenoriginal_statusis set, or the status is inallowed_status_codes). A request whose body is a file or a stream is not retried either.Retry-Afterthe API sends, when that is whole seconds (0.25 to 30 seconds). Otherwise it waits a random time between 0.25 seconds and a ceiling that starts at 1 second and doubles.What you may notice when you upgrade
429or503can now wait up to about 90 seconds before it fails.PUTis no longer retried. The old client retried it on a503whenretrieswas set.retry_max_waitandretry_budget. An invalidretries,retry_max_waitorretry_budgetnow raisesValueErrorwhen you create the client.