Retry-After tells a client how long it ought to wait before a follow-up request. It is a scheduling signal, not proof that the next attempt will succeed or that replaying the original operation is safe. A client still needs a deadline, an attempt policy, and a decision about which operations it can retry.
Interpret the value and its context
RFC 9110 permits either a nonnegative decimal integer number of seconds or an HTTP date. Delay-seconds are measured after receiving the response, not after sending the original request. An HTTP date names an absolute UTC time, so interpreting it depends on the client’s wall clock being reasonably accurate.
Senders generate the preferred IMF-fixdate form. Recipients parsing HTTP dates must accept the two obsolete forms as well; the two-digit year in the old RFC 850 form has a fifty-year interpretation rule. Negative numbers, fractional seconds, and arbitrary words are not valid delay-seconds. Zero and a date already in the past do not require additional server-directed waiting.
With 503, the field estimates how long the service is expected to be unavailable. With a 3xx response, it asks for a minimum wait before the redirected request. RFC 6585 separately permits Retry-After on 429 responses. It is optional there, not a header every throttling response is required to contain.
Respect the wait without waiting forever
A valid wait can exceed the caller’s remaining deadline. The client can stop retrying and report failure or arrange a separate authorized workflow. It should not shorten a server-requested wait and retry early merely to fit a convenient local cap. A long value is not automatically malformed.
After converting an absolute date into a delay, schedule and account for elapsed time with a monotonic clock. Recheck the deadline before the actual attempt, reserving time for that attempt to finish. Clock skew, response transit time, and scheduling delays are reasons to distinguish a plan from observed retry timing.
When the field is absent or unusable, a bounded client policy can use its own backoff. That is a client decision, not a server-supplied recovery estimate. Keep maximum attempts and total elapsed time explicit so that an optional field does not turn transient failure into unlimited retries.
The same delay can synchronize callers
If thousands of clients receive the same delay at about the same time, they can return together. Retry-After alone does not spread them across a recovery window. Add nonnegative jitter after respecting the requested delay, or otherwise schedule no earlier than that boundary.
Combining local backoff and server guidance requires preserving both constraints. The example takes the larger of its local backoff and parsed server delay, then adds positive jitter. Its injected offsets demonstrate the scheduling relationship; they are not measurements of real distributed traffic or a recommendation for a particular production jitter range.
When it breaks
Blindly replaying a write can duplicate side effects when the original outcome was uncertain. A 429 or 503 response and a Retry-After field do not grant an idempotency guarantee. Use the application’s retry contract, including any operation identifiers or deduplication rules, before repeating nonidempotent work.
Libraries also differ in automatic retries and redirect handling. Verify which statuses, methods, and fields the actual client honors. The fixture’s policy retries only GET and HEAD after 429 or 503; it deliberately does not follow redirects or replay POST. That is a bounded example policy, not the full set of methods HTTP considers idempotent.
Malformed values, huge waits, stale dates, exhausted deadlines, and attempt limits belong in tests. Observe actual attempts and delay rather than assuming that configuring a header caused clients to obey it. The service can remain unavailable after its estimate, so retries still need a terminal outcome.
Check it quickly
Save the block as retry_check.py and run python3 retry_check.py with Python 3.10 or newer. It ran with Python 3.14.4. Only the standard library is required. The script makes real requests to its own loopback HTTP server, uses a fixed fixture date and injected clocks, then stops that server. It does not sleep until a retry or contact an external service.
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime, parsedate_tz
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
import re
from threading import Thread
UTC = timezone.utc
REFERENCE = datetime(2026, 9, 8, 12, 0, 0, tzinfo=UTC)
def delay_seconds(value, received_wall):
if value is None:
return None
value = value.strip(" \t")
if re.fullmatch(r"[0-9]+", value):
# Huge decimal values become infinity and fail the bounded retry budget.
return float(value)
parts = parsedate_tz(value)
if parts is None:
return None
year, month, day, hour, minute, second = parts[:6]
old_date = re.fullmatch(r"[A-Za-z]+, [0-9]{2}-[A-Za-z]{3}-([0-9]{2}) [0-9:]{8} GMT", value)
if old_date:
now = datetime.fromtimestamp(received_wall, UTC)
year = now.year // 100 * 100 + int(old_date[1])
if (year, month, day, hour, minute, second) > (
now.year + 50, now.month, now.day, now.hour, now.minute, now.second
):
year -= 100
if not 0 <= second <= 60:
return None
try:
stamp = datetime(year, month, day, hour, minute, min(second, 59), tzinfo=UTC)
stamp += timedelta(seconds=(second == 60) - parts[9])
except (ValueError, OverflowError):
return None
return max(0.0, stamp.timestamp() - received_wall)
def retry_at(status, method, value, received_wall, monotonic_now, remaining, attempt, jitter=0.25):
assert attempt >= 1 and jitter >= 0
if status not in (429, 503) or method not in ("GET", "HEAD") or attempt >= 3:
return None
server_delay = delay_seconds(value, received_wall)
wait = max(server_delay or 0.0, 2 ** (attempt - 1)) + jitter
if wait >= remaining:
return None
return monotonic_now + wait
wall = REFERENCE.timestamp()
for value in ("Tue, 08 Sep 2026 12:00:07 GMT", "Tuesday, 08-Sep-26 12:00:07 GMT",
"Tue Sep 8 12:00:07 2026"):
assert delay_seconds(value, wall) == 7
assert delay_seconds("Sunday, 06-Nov-94 08:49:37 GMT", wall) == 0
assert delay_seconds("Monday, 08-Sep-70 12:00:00 GMT", wall) == (
datetime(2070, 9, 8, 12, tzinfo=UTC).timestamp() - wall
)
assert delay_seconds("Sat, 31 Dec 2016 23:59:60 GMT",
datetime(2016, 12, 31, 23, 59, 59, tzinfo=UTC).timestamp()) == 1
for invalid in ("-1", "1.5", "later", "", "٣", "Tue, 31 Feb 2026 12:00:00 GMT"):
assert delay_seconds(invalid, wall) is None
assert delay_seconds(" 0003\t", wall) == 3
assert retry_at(503, "GET", "9" * 5000, wall, 100, 10, 1) is None
assert retry_at(503, "GET", "3", wall, 100, 2, 1) is None
assert retry_at(503, "POST", "3", wall, 100, 10, 1) is None
assert retry_at(503, "GET", "3", wall, 100, 10, 3) is None
print("both delay forms, obsolete dates, year interpretation, and invalid values are checked")
print("an oversized wait, exhausted deadline, POST, or attempt cap stops this retry policy")
cases = {
"/seconds": (429, "3"),
"/date": (503, format_datetime(REFERENCE + timedelta(seconds=7), usegmt=True)),
"/invalid": (503, "later"),
"/missing": (503, None),
"/redirect": (307, "3"),
"/ready": (200, None),
}
visited = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
visited.append(self.path)
status, value = cases.get(self.path, (404, None))
self.send_response_only(status)
self.send_header("Date", format_datetime(REFERENCE, usegmt=True))
if value is not None:
self.send_header("Retry-After", value)
if status == 307:
self.send_header("Location", "/ready")
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *_):
pass
with HTTPServer(("127.0.0.1", 0), Handler) as server:
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
thread.start()
try:
for route, expected in (("/seconds", 103.25), ("/date", 107.25),
("/invalid", 101.25), ("/missing", 101.25), ("/redirect", None)):
client = HTTPConnection(*server.server_address, timeout=2)
try:
client.request("GET", route)
response = client.getresponse()
response.read()
planned = retry_at(response.status, "GET", response.getheader("Retry-After"),
wall, 100, 10, 1)
assert planned == expected
print(route, "status", response.status, "planned monotonic time", planned)
finally:
client.close()
assert "/ready" not in visited
finally:
server.shutdown()
thread.join(timeout=2)
assert not thread.is_alive()
same_delay = [retry_at(429, "GET", "3", wall, 100, 10, 1, jitter=0) for _ in range(4)]
spread = [retry_at(429, "GET", "3", wall, 100, 10, 1, jitter=j) for j in (0.1, 0.2, 0.3, 0.4)]
assert same_delay == [103] * 4
assert len(set(spread)) == 4 and min(spread) > 103
print("identical server delays synchronize plans; injected positive jitter spreads them:", spread)
Parser checks include both value forms, obsolete dates, year interpretation, a leap-second boundary, invalid inputs, and an extremely long numeric value. The bounded policy abandons a wait exceeding its deadline, POST replay, and attempts beyond its cap.
The local 429 with three seconds plans time 103.25 from injected monotonic time 100. The dated 503 plans 107.25. Missing or invalid fields use the stated local backoff, while the redirect is not followed. Identical delays produce four plans at 103; supplied positive jitter produces 103.1 through 103.4.
These are response and scheduling-decision tests. Actual timed retries, clock corrections, network partitions, proxy behavior, production jitter distributions, and business-operation deduplication remain untested.
Further reading
Sources
- RFC 9110, sections 5.6.7 and 10.2.3 define HTTP dates and Retry-After, checked 2026-09-08.
- RFC 6585, optional Retry-After with 429 responses, checked 2026-09-08.
- Python email utilities, date parsing, UTC offsets, and formatted timestamps, checked 2026-09-08.