1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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));
}
|