summaryrefslogtreecommitdiffstats
path: root/src/retry.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/retry.zig')
-rw-r--r--src/retry.zig152
1 files changed, 152 insertions, 0 deletions
diff --git a/src/retry.zig b/src/retry.zig
new file mode 100644
index 0000000..5caab09
--- /dev/null
+++ b/src/retry.zig
@@ -0,0 +1,152 @@
+//! 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));
+}