Skip to content

Commit 51e7486

Browse files
committed
Implement the use of Retry-After HTTP header to set retry delay
1 parent e839bb7 commit 51e7486

File tree

1 file changed

+108
-4
lines changed

1 file changed

+108
-4
lines changed

src/Package/Fetch.zig

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,19 +336,26 @@ pub const Retry = struct {
336336
///
337337
/// 0 means it should never retry.
338338
max_retries: u16 = 3,
339+
/// Normally, the retry delay is calculated with jitter, but some instances
340+
/// may require an override. When this value is used, it is set back to
341+
/// `null`.
342+
retry_delay_override_ms: ?u32 = null,
339343
/// Hard cap of how many milliseconds to wait between retries.
340344
const MAX_RETRY_SLEEP_MS = 10 * 1000;
341345
/// Minimum time a jittered retry delay could be
342346
const MIN_RETRY_JITTER_MS = 500;
343347
/// Maximum time a jittered retry delay could be
344348
const MAX_RETRY_JITTER_MS = 1500;
345349

346-
fn retryDelayMs(r: *Retry) i64 {
350+
fn calcRetryDelayMs(r: *Retry) i64 {
351+
if (r.retry_delay_override_ms) |delay| {
352+
r.retry_delay_override_ms = null;
353+
return delay;
354+
}
347355
if (r.cur_retries == 0) {
348356
return std.crypto.random.intRangeAtMost(i64, MIN_RETRY_JITTER_MS, MAX_RETRY_JITTER_MS);
349-
} else {
350-
return @min(r.cur_retries * 3 * 1000, MAX_RETRY_SLEEP_MS);
351357
}
358+
return @min(r.cur_retries * 3 * 1000, MAX_RETRY_SLEEP_MS);
352359
}
353360

354361
fn callWithRetries(
@@ -361,7 +368,7 @@ pub const Retry = struct {
361368
while (true) {
362369
return @call(.auto, callback, args) catch |err| {
363370
if (maybeSpurious(err) and r.cur_retries < r.max_retries) {
364-
const delay = Io.Duration.fromMilliseconds(r.retryDelayMs());
371+
const delay = Io.Duration.fromMilliseconds(r.calcRetryDelayMs());
365372
try io.sleep(delay, .awake);
366373
r.cur_retries += 1;
367374
continue;
@@ -1134,6 +1141,13 @@ fn initHttpResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer:
11341141
const status = response.head.status;
11351142

11361143
if (@intFromEnum(status) >= 500 or status == .too_many_requests or status == .not_found) {
1144+
if (parseRetryAfter(f.io, response) catch null) |delay_sec| {
1145+
// Set max by dividing and multiplying again, because Retry-After
1146+
// header value needs to be u32, and could be obsurdly large, and
1147+
// we do not want to multiply that large number by 1000 in case of
1148+
// overflow. So we cap it first, then convert to milliseconds.
1149+
f.retry.retry_delay_override_ms = @min(delay_sec, Retry.MAX_RETRY_SLEEP_MS / 1000) * @as(u32, 1000);
1150+
}
11371151
return BadHttpStatus.MaybeSpurious;
11381152
} else if (status != .ok) {
11391153
return BadHttpStatus.NonSpurious;
@@ -1142,6 +1156,96 @@ fn initHttpResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer:
11421156
resource.http_request.decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
11431157
}
11441158

1159+
/// Iterate through response headers to find Retry-After header, and parse the
1160+
/// value to return a value that represents how many seconds from now to wait
1161+
/// until a retry may be attempted.
1162+
///
1163+
/// If the header does not exist, `null` is returned. If the header value is
1164+
/// invalid, an error is returned to indicate that parsing failed.
1165+
///
1166+
/// Note that the implementation of this method in Cargo only respects this
1167+
/// header for 503 and 429 status codes, but the specification on MDN does not
1168+
/// restrict this header's usage to only those two status codes, so we won't
1169+
/// either.
1170+
///
1171+
/// For more information, see the MDN documentation:
1172+
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
1173+
fn parseRetryAfter(io: Io, response: *const std.http.Client.Response) !?u32 {
1174+
var iter = response.head.iterateHeaders();
1175+
const retry_after: []const u8 = while (iter.next()) |header| {
1176+
if (ascii.eqlIgnoreCase(header.name, "retry-after")) {
1177+
break header.value;
1178+
}
1179+
} else {
1180+
return null;
1181+
};
1182+
1183+
// Option 1: The value is a positive integer indicating number of seconds
1184+
// to wait before retrying.
1185+
if (std.fmt.parseInt(u32, retry_after, 10) catch null) |value| {
1186+
return value;
1187+
}
1188+
1189+
// Option 2: The value is a timestamp that we need to parse, then use with
1190+
// current time to calculate how many seconds to wait before retrying.
1191+
//
1192+
// Parsing based on this specification:
1193+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Date
1194+
const RETRY_AFTER_LEN: usize = 29;
1195+
if (retry_after.len != RETRY_AFTER_LEN) {
1196+
return error.InvalidHeaderValueLength;
1197+
}
1198+
1199+
// Much more memory compact than an array of string slices, because
1200+
// pointers are large. Also 12 strings means 11 more `\0` bytes than we
1201+
// actually need.
1202+
const months = "JanFebMarAprMayJunJulAugSepOctNovDec";
1203+
1204+
const epoch = std.time.epoch;
1205+
1206+
// Example date with periodic indices for easy visualization:
1207+
// Tue, 29 Oct 2024 16:56:32 GMT
1208+
// ^ ^ ^
1209+
// 0 10 20
1210+
const year = try std.fmt.parseInt(epoch.Year, retry_after[12..16], 10);
1211+
const month: epoch.Month = for (0..12) |i| {
1212+
const month = months[i * 3 .. (i * 3) + 3];
1213+
if (std.mem.eql(u8, month, retry_after[8..11])) {
1214+
break @enumFromInt(i + 1);
1215+
}
1216+
} else {
1217+
return error.CannotFindMonth;
1218+
};
1219+
const day = try std.fmt.parseInt(epoch.Day, retry_after[5..7], 10);
1220+
const hour = try std.fmt.parseInt(epoch.Hour, retry_after[17..19], 10);
1221+
const minute = try std.fmt.parseInt(epoch.Minute, retry_after[20..22], 10);
1222+
const second = try std.fmt.parseInt(epoch.Second, retry_after[23..25], 10);
1223+
1224+
const datetime = epoch.Datetime{
1225+
.year = year,
1226+
.month = month,
1227+
.day = day,
1228+
.hour = hour,
1229+
.minute = minute,
1230+
.second = second,
1231+
};
1232+
const timestamp_retry_after: Io.Timestamp = try datetime.asTimestamp();
1233+
const timestamp_cur = try Io.Clock.Timestamp.now(io, .real);
1234+
1235+
// If Retry-After is before or equal to now, disregard it and calc delay as usual
1236+
if (timestamp_retry_after.nanoseconds <= timestamp_cur.raw.nanoseconds) {
1237+
return null;
1238+
}
1239+
1240+
// This is guaranteed to be positive, so represent as u48
1241+
const diff: u48 = @intCast(timestamp_retry_after.nanoseconds - timestamp_cur.raw.nanoseconds);
1242+
1243+
// If we get a number too large to fit into u32, it's way too big anyway, we can cap at u32's max
1244+
const capped: u32 = @min(diff, 2 ** 32);
1245+
1246+
return capped;
1247+
}
1248+
11451249
fn initGitResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer: []u8) !void {
11461250
const eb = &f.error_bundle;
11471251

0 commit comments

Comments
 (0)