diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
| commit | 87166a5299aa551517a86379a0c05eb2110b52b0 (patch) | |
| tree | 3e77e7dd89cca736a22a5f2219451aa57dd99068 /src/retry.zig | |
| download | zig-87166a5299aa551517a86379a0c05eb2110b52b0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/retry.zig')
| -rw-r--r-- | src/retry.zig | 152 |
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)); +} |