http.retry-after
Seconds to wait from an HTTP Retry-After header (delay-seconds or an HTTP date), or null if missing or malformed.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 23 tests, run in TypeScript, Python and Rust.
What it does
How long to wait before trying again, from a `Retry-After` header on a 429 (Too Many Requests) or 503 response:
retryAfterSeconds("120", now) # 120
retryAfterSeconds("Wed, 21 Oct 2015 07:28:00 GMT", 1445412420) # 60
retryAfterSeconds(null, now) # null: no header
retryAfterSeconds("soon", now) # null: not a Retry-After
For example
retry_after_seconds(120, 1,790,424,000)→ 120 delay-seconds, as an API's 429 sends itretry_after_seconds(0, 1,790,424,000)→ 0 zero means retry nowretry_after_seconds(—, 1,790,424,000)→ — no header at all
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn retry_after_seconds(header: Option<&str>, now: i64) -> Option<i64>
| header | string? | the Retry-After header's value, or null when the response has none |
| now | int | the current time in Unix seconds, read by the caller, for the date form |
| returns | int? | whole seconds to wait, 0 or more; null when absent or not a form RFC 9110 allows |
Your code names it in one line, in the file that uses it
fune!(http.retry-after@^1); // then call retry_after_seconds(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_is_leap_year::is_leap_year; ← from dates.is-leap-year ^1.0.0 · built alongside by fune
use super::time_iso_to_unix::iso_to_unix; ← from time.iso-to-unix ^1.0.0 · built alongside by fune
const DAY_NAMES: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MONTH_DAYS: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Nine digits is 31 years; anything longer is a broken header, not a wait.
const MAX_DIGITS: usize = 9;
fn all_digits(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
}
fn number(text: &str) -> i64 {
text.bytes().fold(0, |n, b| n * 10 + (b - b'0') as i64)
}
/// An IMF-fixdate, "Sun, 06 Nov 1994 08:49:37 GMT", to Unix seconds; None if it is not one.
fn imf_fixdate(t: &str) -> Option<i64> {
// Byte positions are only safe once the text is known to be ASCII.
if t.len() != 29 || !t.is_ascii() {
return None;
}
if !DAY_NAMES.contains(&&t[0..3]) || &t[3..5] != ", " || &t[7..8] != " " || &t[11..12] != " " || &t[16..17] != " " {
return None;
}
if &t[19..20] != ":" || &t[22..23] != ":" || &t[25..] != " GMT" {
return None;
}
let month = MONTHS.iter().position(|m| *m == &t[8..11])?;
let (dd, yyyy, hh, mi, ss) = (&t[5..7], &t[12..16], &t[17..19], &t[20..22], &t[23..25]);
if ![dd, yyyy, hh, mi, ss].iter().all(|part| all_digits(part)) {
return None;
}
let (year, day) = (number(yyyy), number(dd));
if year < 1 || number(hh) > 23 || number(mi) > 59 || number(ss) > 59 {
return None;
}
let month_days = if month == 1 && is_leap_year(year) { 29 } else { MONTH_DAYS[month] };
if day < 1 || day > month_days {
return None;
}
Some(iso_to_unix(&format!("{}-{:02}-{}T{}:{}:{}Z", yyyy, month + 1, dd, hh, mi, ss)))
}
/// How long a client should wait before retrying, from a Retry-After header
/// (RFC 9110 10.2.3): delay-seconds, or an HTTP date turned into seconds from
/// `now`. A date in the past is 0. Anything else is None.
pub fn retry_after_seconds(header: Option<&str>, now: i64) -> Option<i64> {
let header = header?;
// HTTP's optional whitespace is space and tab only (RFC 9110 5.6.3).
let t = header.trim_matches(|c| c == ' ' || c == '\t');
if all_digits(t) {
return if t.len() <= MAX_DIGITS { Some(number(t)) } else { None };
}
let at = imf_fixdate(t)?;
Some(if at > now { at - now } else { 0 })
}
pub fn fune_vector(args: &[Value]) -> Value {
let now = match &args[1] {
Value::Int(i) => *i,
_ => panic!("now must be a whole number of seconds"),
};
let header = match &args[0] {
Value::Str(s) => Some(s.as_str()),
_ => None,
};
match retry_after_seconds(header, now) {
Some(seconds) => Value::Int(seconds),
None => Value::Null,
}
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add http.retry-after
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./http.retry-after-1.0.0-rust.fune, or fetch it from a terminal with fune pull http.retry-after@1.0.0:rust.
The whole function, every language, is one file too: http.retry-after-1.0.0.fune, 14,698 bytes, sha256 441e33fb277a014ea37cbffe29dc8e4ca7354b82390b91db5d0d68e5a6d79a3e. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before http.retry-after
after — your function gets the result and the arguments, and returns the final result.
// fune: after http.retry-after
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.is-leap-year in http.retry-after
// fune: replace time.iso-to-unix in http.retry-after
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show http.retry-after --steps.
// fune: step http.retry-after after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| delay-seconds, as an API's 429 sends it | 120, 1,790,424,000 | → | 120 |
| zero means retry now | 0, 1,790,424,000 | → | 0 |
| no header at all | —, 1,790,424,000 | → | — |
| an empty header | , 1,790,424,000 | → | — |
| spaces and tabs around the value are optional whitespace | 30 , 1,790,424,000 | → | 30 |
| leading zeros are still 1*DIGIT | 007, 1,790,424,000 | → | 7 |
| nine digits is the most accepted | 999999999, 0 | → | 999,999,999 |
| ten digits is a broken header, not a 317-year wait | 1000000000, 0 | → | — |
| a negative number is not delay-seconds | -5, 1,790,424,000 | → | — |
| a fraction is not delay-seconds | 1.5, 1,790,424,000 | → | — |
Show the other 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a plus sign is not delay-seconds (Rust's parse would take it) | +5, 1,790,424,000 | → | — |
| Arabic-Indic digits are not ASCII digits | ٣٠, 1,790,424,000 | → | — |
| a trailing newline is not optional whitespace | 120 , 1,790,424,000 | → | — |
| an HTTP date a minute ahead is 60 seconds | Wed, 21 Oct 2015 07:28:00 GMT, 1,445,412,420 | → | 60 |
| an HTTP date already past is 0, not negative | Wed, 21 Oct 2015 07:28:00 GMT, 1,445,412,490 | → | 0 |
| 29 February in a leap year | Thu, 29 Feb 2024 12:00:00 GMT, 1,709,207,000 | → | 1,000 |
| 29 February in a common year is no date | Wed, 29 Feb 2023 12:00:00 GMT, 1,600,000,000 | → | — |
| the zone must be GMT, upper case | Wed, 21 Oct 2015 07:28:00 gmt, 1,445,412,420 | → | — |
| hour 24 is refused | Wed, 21 Oct 2015 24:00:00 GMT, 1,445,412,420 | → | — |
| year 0000 is refused | Sat, 01 Jan 0000 00:00:00 GMT, 0 | → | — |
| the obsolete RFC 850 form is not accepted | Wednesday, 21-Oct-15 07:28:00 GMT, 1,445,412,420 | → | — |
| the obsolete asctime form is not accepted | Wed Oct 21 07:28:00 2015, 1,445,412,420 | → | — |
| a fractional now is refused | 120, 1,790,424,000.5 | → | error: now must be a whole number of seconds |
More from the author
RFC 9110 section 10.2.3 allows two forms:
- **delay-seconds**: one or more ASCII digits (`120`, `0`, `007`). Signs, fractions, other scripts' digits and more than nine digits (31 years) are not a wait anyone meant, so they are null. - **HTTP-date**, in the preferred IMF-fixdate form (`Sun, 06 Nov 1994 08:49:37 GMT`, section 5.6.7): exactly that layout, day and month names in English with that capitalisation, `GMT` in capitals, a real calendar date (29 February only in a leap year, via `dates.is-leap-year`), hours 00-23 and seconds 00-59. It is turned into seconds from `now` through `time.iso-to-unix`; a date already past is 0. The day name is not checked against the date. The obsolete RFC 850 and asctime forms, which a recipient "SHOULD" accept, are refused here: nothing still sends them, and each is a two-digit-year or zone ambiguity.
Spaces and tabs around the value are ignored (HTTP's optional whitespace); anything else, a newline included, is null. Null is a signal, not an error: the caller falls back to its own backoff rather than trusting a garbled header. The caller reads the clock and passes `now` in whole Unix seconds; a fraction throws "now must be a whole number of seconds".
Pair it with `time.countdown(now + seconds, now)` to show the wait ticking down.
Source: RFC 9110, HTTP Semantics, sections 10.2.3 (Retry-After) and 5.6.7 (Date/Time Formats), https://www.rfc-editor.org/rfc/rfc9110.
Files
| Path | Bytes |
|---|---|
| README.md | 1,880 |
| impl/python.py | 2,337 |
| impl/rust.rs | 2,903 |
| impl/typescript.ts | 2,768 |
| vectors.json | 2,459 |