Functional Weave
Code in TypeScript

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

  • retryAfterSeconds(120, 1,790,424,000) → 120 delay-seconds, as an API's 429 sends it
  • retryAfterSeconds(0, 1,790,424,000) → 0 zero means retry now
  • retryAfterSeconds(—, 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.

export function retryAfterSeconds(header: string | null, now: number): number | null
headerstring?the Retry-After header's value, or null when the response has none
nowintthe current time in Unix seconds, read by the caller, for the date form
returnsint?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

import { retryAfterSeconds } from "#fune/http.retry-after@^1";
impl/typescript.ts · 58 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { isLeapYear } from "./dates_is_leap_year.ts";  ← from dates.is-leap-year ^1.0.0 · built alongside by fune
import { isoToUnix } from "./time_iso_to_unix.ts";  ← from time.iso-to-unix ^1.0.0 · built alongside by fune

const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MONTH_DAYS = [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 = 9;

function allDigits(text: string): boolean {
  if (text.length === 0) return false;
  for (let i = 0; i < text.length; i++) {
    const c = text.charCodeAt(i);
    if (c < 48 || c > 57) return false;
  }
  return true;
}

function trimOws(text: string): string {
  // HTTP's optional whitespace is space and tab only (RFC 9110 5.6.3).
  let start = 0;
  let end = text.length;
  while (start < end && (text[start] === " " || text[start] === "\t")) start++;
  while (end > start && (text[end - 1] === " " || text[end - 1] === "\t")) end--;
  return text.slice(start, end);
}

/** An IMF-fixdate, "Sun, 06 Nov 1994 08:49:37 GMT", to Unix seconds; null if it is not one. */
function imfFixdate(t: string): number | null {
  if (t.length !== 29) return null;
  if (!DAY_NAMES.includes(t.slice(0, 3)) || t.slice(3, 5) !== ", " || t[7] !== " " || t[11] !== " " || t[16] !== " ") return null;
  if (t[19] !== ":" || t[22] !== ":" || t.slice(25) !== " GMT") return null;
  const month = MONTHS.indexOf(t.slice(8, 11));
  const dd = t.slice(5, 7), yyyy = t.slice(12, 16), hh = t.slice(17, 19), mi = t.slice(20, 22), ss = t.slice(23, 25);
  if (month < 0 || ![dd, yyyy, hh, mi, ss].every(allDigits)) return null;
  const year = Number(yyyy), day = Number(dd);
  if (year < 1 || Number(hh) > 23 || Number(mi) > 59 || Number(ss) > 59) return null;
  const monthDays = month === 1 && isLeapYear(year) ? 29 : MONTH_DAYS[month];
  if (day < 1 || day > monthDays) return null;
  const mm = String(month + 1).padStart(2, "0");
  return isoToUnix(`${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}Z`);
}

/**
 * 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 null, so the caller picks
 * its own backoff instead of trusting a garbled header.
 */
export function retryAfterSeconds(header: string | null, now: number): number | null {
  if (typeof now !== "number" || !Number.isSafeInteger(now)) throw new RangeError("now must be a whole number of seconds");
  if (typeof header !== "string") return null;
  const t = trimOws(header);
  if (allDigits(t)) return t.length <= MAX_DIGITS ? Number(t) : null;
  const at = imfFixdate(t);
  if (at === null) return null;
  return at > now ? at - now : 0;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:

fune add http.retry-after
Download for TypeScript http.retry-after-1.0.0-typescript.fune · 9,170 bytes sha256 5e349d64b2618be65a48ef0179fac8ad2c015d7ae1b2c54e7e198079ff1c36ae

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./http.retry-after-1.0.0-typescript.fune, or fetch it from a terminal with fune pull http.retry-after@1.0.0:typescript.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,880
impl/python.py2,337
impl/rust.rs2,903
impl/typescript.ts2,768
vectors.json2,459