Retry-After header on 429 and 503: seconds vs date, and what curl and Python do
Retry-After tells a client how long to wait before trying again, either as seconds or as a date. We measured whether curl and Python's requests actually wait for it.
1. The two formats
HTTP/1.1 429 Too Many Requests
Retry-After: 3
HTTP/1.1 503 Service Unavailable
Retry-After: Thu, 01 Oct 2026 12:10:03 GMT
Seconds is simpler and doesn't depend on clocks. A date has one-second resolution: in our test a date "3 seconds ahead" produced a first wait of 2.17 s, because the fraction of the current second was cut off.
2. What the clients did (gaps between the 3 requests)
| Client | Retry-After: 3 | Date +3 s | No header |
|---|---|---|---|
curl --retry 5 | 3.0 s, 3.0 s | 3.0 s, 3.0 s | 1 s, 2 s (doubling) |
requests + urllib3 Retry(status_forcelist=[429, 503]) | 3.0 s, 3.0 s | 2.17 s, 3.0 s | 0 s, 1 s (backoff_factor 0.5) |
- curl retries 429 and 503 without extra flags, and
--retry-delay 1did not shorten a Retry-After of 3 s: the header won. - urllib3 waited for Retry-After on 429/503 even without
status_forcelist.respect_retry_after_header=Falsemade it ignore the header (gaps 0 s, 1 s).
3. The trap: a huge Retry-After
With Retry-After: 300, the Python script simply slept: it was still waiting when we killed it after 8 seconds. urllib3 has no upper limit on that wait. Cap it yourself:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class CappedRetry(Retry):
def get_retry_after(self, response):
wait = super().get_retry_after(response)
return None if wait is None else min(wait, 30) # never sleep more than 30 s
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=CappedRetry(total=5, backoff_factor=0.5, status_forcelist=[429, 503])))
r = s.get("https://api.example.com/items")
With the cap at 2 s, the same 300 s server got retries 2.0 s apart and the call succeeded. curl instead gives up: curl --retry 5 --retry-max-time 10 returned the 429 at once, because 300 s doesn't fit in 10 s. Use --retry-max-time on anything that runs unattended.
4. Sending it from your server
# nginx rate limiting: return 429 instead of the default 503
limit_req_status 429;
# add the header on those responses (adjust to your limit)
add_header Retry-After 10 always;
The nginx lines were not part of this test. Check what your server sends with curl -sI https://your-site/ | grep -i retry-after or the free HTTP headers tool.
Frequently asked questions
Is Retry-After in seconds or milliseconds?
Seconds (a whole number), or an HTTP date. There is no millisecond form.
Does Python requests respect Retry-After?
Only with a urllib3 Retry mounted on the session. Then it waited exactly the header's 3 seconds in our test, even without status_forcelist. Plain requests.get() doesn't retry at all.
Does curl --retry respect Retry-After?
Yes. curl 8.14 waited 3 s for Retry-After: 3 (seconds or date), and the header beat --retry-delay.
What happens if Retry-After is very long?
urllib3 sleeps the whole time (our 300 s test hung the script). Cap it with a Retry subclass; curl with --retry-max-time gives up instead.
Written with AI assistance. Measured on 2026-10-01 against a local server that answered 429 or 503 twice, then 200, logging the time of every request: curl 8.14.1 and Python requests 2.32.3 with urllib3 2.3.0. Retry-After was sent as seconds (3), as an HTTP date 3 seconds ahead, as 300 seconds, or not at all. PlainHeaders is our extension; curl and Python are free.