//! Backoff arithmetic and `Retry-After` parsing. //! //! Everything here is pure: the clock and the randomness arrive as arguments, //! which is what makes the policy testable without a network or a sleep. const std = @import("std"); /// A random wait in `[0, ceiling_ms]` — *full jitter*. /// /// The randomness is not decoration: without it, N clients that fail together /// come back together and reproduce the spike that took the service down. pub fn jitter(random: std.Random, ceiling_ms: u64) u64 { if (ceiling_ms == 0) return 0; return random.uintLessThan(u64, ceiling_ms + 1); } /// The ceiling of the exponential wait for `attempt`, capped at `max_ms`. /// /// `attempt` counts from 1, so the first wait is `base_ms`, the second twice /// that, and so on until the cap. pub fn ceilingMs(attempt: u32, base_ms: u64, max_ms: u64) u64 { if (base_ms == 0) return 0; const shift: u6 = @intCast(@min(attempt -| 1, 63)); const scaled = std.math.shlExact(u64, base_ms, shift) catch return max_ms; return @min(scaled, max_ms); } /// Parse the `Retry-After` header in both RFC 9110 forms, in milliseconds. /// /// It may arrive as a number of seconds (`"120"`) or as an HTTP date /// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common /// bug, and it makes a client ignore exactly the wait request that matters /// most. /// /// `now_unix` is the current time in seconds since the Unix epoch, so a date /// can be turned into a delay. A date already in the past yields 0; anything /// unparseable yields null. pub fn parseRetryAfter(value: []const u8, now_unix: i64) ?u64 { const text = std.mem.trim(u8, value, " \t"); if (text.len == 0) return null; if (std.fmt.parseFloat(f64, text)) |seconds| { if (!std.math.isFinite(seconds)) return null; if (seconds <= 0) return 0; return @intFromFloat(@min(seconds * 1000.0, @as(f64, @floatFromInt(std.math.maxInt(u64))))); } else |_| {} const target = httpDateToUnix(text) orelse return null; if (target <= now_unix) return 0; const delta: u64 = @intCast(target - now_unix); return delta *| 1000; } /// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must /// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110 /// still mentions are not accepted. pub fn httpDateToUnix(value: []const u8) ?i64 { const rest = if (std.mem.indexOf(u8, value, ", ")) |i| value[i + 2 ..] else value; var parts = std.mem.tokenizeAny(u8, rest, " \t"); const day = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; const month = monthNumber(parts.next() orelse return null) orelse return null; const year = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; var clock = std.mem.splitScalar(u8, parts.next() orelse return null, ':'); const hour = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; const minute = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; const second = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null; if (day < 1 or day > 31) return null; if (hour < 0 or hour > 23) return null; if (minute < 0 or minute > 59) return null; if (second < 0 or second > 60) return null; return daysFromCivil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second; } fn monthNumber(name: []const u8) ?i64 { const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", }; for (months, 0..) |month, i| { if (std.mem.eql(u8, month, name)) return @intCast(i + 1); } return null; } /// Days between 1970-01-01 and the given proleptic Gregorian date. fn daysFromCivil(year_in: i64, month: i64, day: i64) i64 { const year = if (month <= 2) year_in - 1 else year_in; const era = @divFloor(if (year >= 0) year else year - 399, 400); const year_of_era = year - era * 400; const month_shifted = @mod(month + 9, 12); const day_of_year = @divTrunc(153 * month_shifted + 2, 5) + day - 1; const day_of_era = year_of_era * 365 + @divTrunc(year_of_era, 4) - @divTrunc(year_of_era, 100) + day_of_year; return era * 146_097 + day_of_era - 719_468; } const testing = std.testing; test "reads plain seconds" { try testing.expectEqual(@as(?u64, 7000), parseRetryAfter(" 7 ", 0)); try testing.expectEqual(@as(?u64, 0), parseRetryAfter("0", 0)); } test "negative seconds become zero" { try testing.expectEqual(@as(?u64, 0), parseRetryAfter("-5", 0)); } test "reads an HTTP date" { try testing.expectEqual(@as(?i64, 1_792_567_680), httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT")); } test "a date in the past is zero" { try testing.expectEqual(@as(?u64, 0), parseRetryAfter("Thu, 01 Jan 1970 00:00:00 GMT", 1_700_000_000)); } test "a date in the future is a delay" { const epoch = httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT").?; try testing.expectEqual(@as(?u64, 60_000), parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT", epoch - 60)); } test "rejects nonsense" { try testing.expectEqual(@as(?u64, null), parseRetryAfter("soon", 0)); try testing.expectEqual(@as(?u64, null), parseRetryAfter("", 0)); try testing.expectEqual(@as(?u64, null), parseRetryAfter(" ", 0)); try testing.expectEqual(@as(?u64, null), parseRetryAfter("Mon, 99 Xyz 2026 07:28:00 GMT", 0)); } test "civil days match known dates" { try testing.expectEqual(@as(i64, 0), daysFromCivil(1970, 1, 1)); try testing.expectEqual(@as(i64, 11_017), daysFromCivil(2000, 3, 1)); try testing.expectEqual(@as(i64, -719_468), daysFromCivil(0, 3, 1)); } test "the ceiling doubles and then caps" { try testing.expectEqual(@as(u64, 500), ceilingMs(1, 500, 8000)); try testing.expectEqual(@as(u64, 1000), ceilingMs(2, 500, 8000)); try testing.expectEqual(@as(u64, 2000), ceilingMs(3, 500, 8000)); try testing.expectEqual(@as(u64, 8000), ceilingMs(9, 500, 8000)); try testing.expectEqual(@as(u64, 8000), ceilingMs(999, 500, 8000)); } test "jitter stays within the ceiling" { var prng: std.Random.DefaultPrng = .init(1234); const random = prng.random(); for (0..1000) |_| { try testing.expect(jitter(random, 50) <= 50); } try testing.expectEqual(@as(u64, 0), jitter(random, 0)); }