diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index 050d72080cc4..32e287405573 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -73,6 +73,7 @@ import "../../fs/unstable_lstat_test.ts"; import "../../fs/unstable_chmod_test.ts"; import "../../fs/unstable_umask_test.ts"; import "../../fs/unstable_utime_test.ts"; +import "../../http/unstable_retry_after_test.ts"; import "../../internal/assertion_state_test.ts"; import "../../path/_common/assert_path_test.ts"; import "../../path/_common/basename_test.ts"; diff --git a/http/deno.json b/http/deno.json index a73e8d1335ad..a886fd84c906 100644 --- a/http/deno.json +++ b/http/deno.json @@ -12,6 +12,7 @@ "./unstable-header": "./unstable_header.ts", "./unstable-method": "./unstable_method.ts", "./unstable-problem-details": "./unstable_problem_details.ts", + "./unstable-retry-after": "./unstable_retry_after.ts", "./negotiation": "./negotiation.ts", "./server-sent-event-stream": "./server_sent_event_stream.ts", "./server-sent-event-parse-stream": "./server_sent_event_parse_stream.ts", diff --git a/http/unstable_retry_after.ts b/http/unstable_retry_after.ts new file mode 100644 index 000000000000..148b3a3c7d63 --- /dev/null +++ b/http/unstable_retry_after.ts @@ -0,0 +1,250 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Parsing of the `Retry-After` HTTP header per + * {@link https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3 | RFC 9110 Section 10.2.3}. + * + * The header value is either a number of seconds (`delta-seconds`) or an + * HTTP-date. {@linkcode parseRetryAfter} accepts both forms and returns the + * number of milliseconds to wait, or `null` when the value cannot be parsed. + * + * @example Honor `Retry-After` from a 429 response + * ```ts + * import { parseRetryAfter } from "@std/http/unstable-retry-after"; + * import { assertEquals } from "@std/assert"; + * + * const response = new Response(null, { + * status: 429, + * headers: { "retry-after": "120" }, + * }); + * + * const delay = parseRetryAfter(response.headers.get("retry-after")) ?? 1000; + * assertEquals(delay, 120_000); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @module + */ + +const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const LONG_DAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const DELTA_SECONDS_REGEXP = /^\d+$/; +// HTTP-date grammars from RFC 9110 Section 5.6.7. Each form is matched +// exactly, including casing and spacing. +const IMF_FIXDATE_REGEXP = + /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const RFC_850_DATE_REGEXP = + /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const ASCTIME_DATE_REGEXP = + /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +/** + * Validates calendar components and converts them to a UTC timestamp. + * + * Second `60` (a leap second) normalizes to the following instant because + * JavaScript `Date` does not represent leap seconds. + */ +function toTimestamp( + weekdayIndex: number, + year: number, + monthIndex: number, + day: number, + hours: number, + minutes: number, + seconds: number, +): number | null { + if (year < 1900) return null; + if (hours > 23 || minutes > 59 || seconds > 60) return null; + const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); + if (day < 1 || day > daysInMonth) return null; + if (new Date(Date.UTC(year, monthIndex, day)).getUTCDay() !== weekdayIndex) { + return null; + } + return Date.UTC(year, monthIndex, day, hours, minutes, seconds); +} + +/** + * Resolves an RFC 850 two-digit year against `now`. Per RFC 9110 Section + * 5.6.7, a timestamp that would be more than 50 years in the future is + * interpreted as the most recent past year with the same final two digits. + */ +function resolveTwoDigitYear( + twoDigitYear: number, + monthIndex: number, + day: number, + hours: number, + minutes: number, + seconds: number, + nowMs: number, +): number { + const now = new Date(nowMs); + const year = Math.floor(now.getUTCFullYear() / 100) * 100 + twoDigitYear; + const threshold = Date.UTC( + now.getUTCFullYear() + 50, + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes(), + now.getUTCSeconds(), + now.getUTCMilliseconds(), + ); + const candidate = Date.UTC(year, monthIndex, day, hours, minutes, seconds); + return candidate > threshold ? year - 100 : year; +} + +function parseHttpDate(value: string, nowMs: number): number | null { + const imf = IMF_FIXDATE_REGEXP.exec(value); + if (imf) { + const [, dayName, day, monthName, year, hours, minutes, seconds] = imf; + return toTimestamp( + DAY_NAMES.indexOf(dayName!), + Number(year), + MONTH_NAMES.indexOf(monthName!), + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ); + } + const rfc850 = RFC_850_DATE_REGEXP.exec(value); + if (rfc850) { + const [, dayName, day, monthName, twoDigitYear, hours, minutes, seconds] = + rfc850; + const monthIndex = MONTH_NAMES.indexOf(monthName!); + const year = resolveTwoDigitYear( + Number(twoDigitYear), + monthIndex, + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + nowMs, + ); + return toTimestamp( + LONG_DAY_NAMES.indexOf(dayName!), + year, + monthIndex, + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ); + } + const asctime = ASCTIME_DATE_REGEXP.exec(value); + if (asctime) { + const [, dayName, monthName, day, hours, minutes, seconds, year] = asctime; + return toTimestamp( + DAY_NAMES.indexOf(dayName!), + Number(year), + MONTH_NAMES.indexOf(monthName!), + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ); + } + return null; +} + +/** + * Options for {@linkcode parseRetryAfter}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface ParseRetryAfterOptions { + /** + * The reference time used to convert an HTTP-date into a delay and to + * resolve RFC 850 two-digit years. + * + * @default {new Date()} + */ + now?: Date; +} + +/** + * Parses the value of a + * {@link https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3 | Retry-After} + * header into the number of milliseconds to wait. + * + * Accepts `delta-seconds` as well as the IMF-fixdate, RFC 850, and asctime + * HTTP-date forms defined in + * {@link https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7 | RFC 9110 Section 5.6.7}. + * An HTTP-date in the past yields `0`. Returns `null` for a missing header, + * invalid syntax, an unrepresentable delay, or an HTTP-date paired with an + * invalid `now`. Never throws for header input. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param value The `Retry-After` header value, or `null` when the header is + * absent. + * @param options Parse options. + * @returns The number of milliseconds to wait from `options.now`, or `null` + * when the value cannot be parsed. + * + * @example Delta-seconds + * ```ts + * import { parseRetryAfter } from "@std/http/unstable-retry-after"; + * import { assertEquals } from "@std/assert"; + * + * assertEquals(parseRetryAfter("120"), 120_000); + * assertEquals(parseRetryAfter("tomorrow"), null); + * ``` + * + * @example HTTP-date + * ```ts + * import { parseRetryAfter } from "@std/http/unstable-retry-after"; + * import { assertEquals } from "@std/assert"; + * + * const now = new Date(Date.UTC(2026, 7, 23, 12, 0, 0)); + * assertEquals( + * parseRetryAfter("Sun, 23 Aug 2026 12:02:00 GMT", { now }), + * 120_000, + * ); + * ``` + */ +export function parseRetryAfter( + value: string | null, + options?: ParseRetryAfterOptions, +): number | null { + if (value === null) return null; + if (DELTA_SECONDS_REGEXP.test(value)) { + const seconds = Number(value); + const milliseconds = seconds * 1000; + if ( + !Number.isSafeInteger(seconds) || !Number.isSafeInteger(milliseconds) + ) { + return null; + } + return milliseconds; + } + const nowMs = (options?.now ?? new Date()).getTime(); + if (Number.isNaN(nowMs)) return null; + const timestamp = parseHttpDate(value, nowMs); + if (timestamp === null) return null; + return Math.max(0, timestamp - nowMs); +} diff --git a/http/unstable_retry_after_test.ts b/http/unstable_retry_after_test.ts new file mode 100644 index 000000000000..5bec6149d109 --- /dev/null +++ b/http/unstable_retry_after_test.ts @@ -0,0 +1,196 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals } from "@std/assert"; +import { parseRetryAfter } from "./unstable_retry_after.ts"; + +// 2026-08-23 is a Sunday. +const NOW = new Date(Date.UTC(2026, 7, 23, 12, 0, 0)); + +Deno.test("parseRetryAfter() returns null for a missing header", () => { + assertEquals(parseRetryAfter(null), null); +}); + +Deno.test("parseRetryAfter() returns null for an empty value", () => { + assertEquals(parseRetryAfter(""), null); +}); + +Deno.test("parseRetryAfter() parses delta-seconds", () => { + assertEquals(parseRetryAfter("120"), 120_000); + assertEquals(parseRetryAfter("0"), 0); +}); + +Deno.test("parseRetryAfter() rejects malformed delta-seconds", () => { + assertEquals(parseRetryAfter("-1"), null); + assertEquals(parseRetryAfter("1.5"), null); + assertEquals(parseRetryAfter(" 120"), null); + assertEquals(parseRetryAfter("120 "), null); +}); + +Deno.test("parseRetryAfter() accepts the largest exactly representable delta-seconds", () => { + // 9007199254740 * 1000 is the largest safe-integer millisecond result. + assertEquals(parseRetryAfter("9007199254740"), 9_007_199_254_740_000); +}); + +Deno.test("parseRetryAfter() returns null when delta-seconds overflow", () => { + assertEquals(parseRetryAfter("9007199254741"), null); + assertEquals(parseRetryAfter("99999999999999999999"), null); +}); + +Deno.test("parseRetryAfter() parses an IMF-fixdate", () => { + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:02:00 GMT", { now: NOW }), + 120_000, + ); +}); + +Deno.test("parseRetryAfter() parses an RFC 850 date", () => { + assertEquals( + parseRetryAfter("Sunday, 23-Aug-26 12:02:00 GMT", { now: NOW }), + 120_000, + ); +}); + +Deno.test("parseRetryAfter() parses an asctime date", () => { + assertEquals( + parseRetryAfter("Sun Aug 23 12:02:00 2026", { now: NOW }), + 120_000, + ); +}); + +Deno.test("parseRetryAfter() parses an asctime date with a space-padded day", () => { + // 2026-08-06 is a Thursday and 17 days before NOW. + assertEquals( + parseRetryAfter("Thu Aug 6 12:00:00 2026", { now: NOW }), + 0, + ); +}); + +Deno.test("parseRetryAfter() keeps an RFC 850 year exactly 50 years in the future", () => { + // 2076-08-23T12:00:00Z is exactly, not more than, 50 years after NOW. + assertEquals( + parseRetryAfter("Sunday, 23-Aug-76 12:00:00 GMT", { now: NOW }), + Date.UTC(2076, 7, 23, 12, 0, 0) - NOW.getTime(), + ); +}); + +Deno.test("parseRetryAfter() resolves an RFC 850 year more than 50 years in the future to the past", () => { + // 2076-08-23T12:00:01Z is more than 50 years after NOW, so the year + // resolves to 1976, in which August 23 was a Monday. + assertEquals( + parseRetryAfter("Monday, 23-Aug-76 12:00:01 GMT", { now: NOW }), + 0, + ); +}); + +Deno.test("parseRetryAfter() normalizes a leap second to the following instant", () => { + const now = new Date(Date.UTC(2016, 11, 31, 23, 59, 0)); + assertEquals( + parseRetryAfter("Sat, 31 Dec 2016 23:59:60 GMT", { now }), + 60_000, + ); +}); + +Deno.test("parseRetryAfter() clamps a past date to 0", () => { + assertEquals( + parseRetryAfter("Sun, 06 Nov 1994 08:49:37 GMT", { now: NOW }), + 0, + ); +}); + +Deno.test("parseRetryAfter() rejects invalid calendar components", () => { + // November has 30 days. + assertEquals( + parseRetryAfter("Mon, 31 Nov 2026 12:00:00 GMT", { now: NOW }), + null, + ); + // 2027 is not a leap year. + assertEquals( + parseRetryAfter("Mon, 29 Feb 2027 12:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 00 Nov 2026 12:00:00 GMT", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects invalid time components", () => { + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 24:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:60:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:00:61 GMT", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects years below 1900", () => { + // 1899-01-01 was a Sunday, so only the year is invalid. + assertEquals( + parseRetryAfter("Sun, 01 Jan 1899 00:00:00 GMT", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects a weekday that disagrees with the date", () => { + assertEquals( + parseRetryAfter("Mon, 23 Aug 2026 12:00:00 GMT", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects wrong casing", () => { + assertEquals( + parseRetryAfter("sun, 23 Aug 2026 12:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 AUG 2026 12:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:00:00 gmt", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects wrong spacing", () => { + assertEquals( + parseRetryAfter("Sun,23 Aug 2026 12:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:00:00 GMT", { now: NOW }), + null, + ); + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:00:00 GMT ", { now: NOW }), + null, + ); + // asctime requires a space-padded single-digit day. + assertEquals( + parseRetryAfter("Thu Aug 6 12:00:00 2026", { now: NOW }), + null, + ); +}); + +Deno.test("parseRetryAfter() rejects garbage", () => { + assertEquals(parseRetryAfter("tomorrow", { now: NOW }), null); + assertEquals(parseRetryAfter("120 seconds", { now: NOW }), null); +}); + +Deno.test("parseRetryAfter() returns null for a date with an invalid now", () => { + assertEquals( + parseRetryAfter("Sun, 23 Aug 2026 12:02:00 GMT", { now: new Date(NaN) }), + null, + ); +}); + +Deno.test("parseRetryAfter() parses delta-seconds despite an invalid now", () => { + assertEquals(parseRetryAfter("120", { now: new Date(NaN) }), 120_000); +});