Skip to content

Commit 7416a9d

Browse files
committed
Implement the use of Retry-After HTTP header to set retry delay
1 parent b409e41 commit 7416a9d

File tree

1 file changed

+106
-3
lines changed

1 file changed

+106
-3
lines changed

src/Package/Fetch.zig

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,15 +333,23 @@ pub const Retry = struct {
333333
///
334334
/// 0 means it should never retry.
335335
max_retries: u16 = 3,
336+
/// Normally, the retry delay is calculated with jitter, but some instances
337+
/// may require an override. When this value is used, it is set back to
338+
/// `null`.
339+
retry_delay_override_ms: ?u32 = null,
336340
/// Hard cap of how many milliseconds to wait between retries.
337341
const MAX_RETRY_SLEEP_MS: u32 = 10 * 1000;
338342
/// Minimum time a jittered retry delay could be
339343
const MIN_RETRY_JITTER_MS: u32 = 500;
340344
/// Maximum time a jittered retry delay could be
341345
const MAX_RETRY_JITTER_MS: u32 = 1500;
342346

343-
fn retryDelayMs(r: *Retry) u32 {
344-
var prng = std.Random.DefaultPrng.init(@as(u64, @intCast(std.time.timestamp())));
347+
fn calcRetryDelayMs(r: *Retry) u32 {
348+
if (r.retry_delay_override_ms) |delay| {
349+
r.retry_delay_override_ms = null;
350+
return delay;
351+
}
352+
var prng = std.Random.DefaultPrng.init(@as(u64, @bitCast(std.time.timestamp())));
345353
const rand = prng.random();
346354
if (r.cur_retries == 0) {
347355
return rand.intRangeAtMost(u32, MIN_RETRY_JITTER_MS, MAX_RETRY_JITTER_MS);
@@ -359,7 +367,7 @@ pub const Retry = struct {
359367
while (true) {
360368
return @call(.auto, callback, args) catch |err| {
361369
if (maybe_spurious(err) and r.cur_retries < r.max_retries) {
362-
std.Thread.sleep(std.time.ns_per_ms * r.retryDelayMs());
370+
std.Thread.sleep(std.time.ns_per_ms * r.calcRetryDelayMs());
363371
r.cur_retries += 1;
364372
continue;
365373
}
@@ -1126,6 +1134,13 @@ fn initHttpResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer:
11261134
const status = response.head.status;
11271135

11281136
if (@intFromEnum(status) >= 500 or status == .too_many_requests or status == .not_found) {
1137+
if (parseRetryAfter(response) catch null) |delay_sec| {
1138+
// Set max by dividing and multiplying again, because Retry-After
1139+
// header value needs to be u32, and could be obsurdly large, and
1140+
// we do not want to multiply that large number by 1000 in case of
1141+
// overflow. So we cap it first, then convert to milliseconds.
1142+
f.retry.retry_delay_override_ms = @min(delay_sec, Retry.MAX_RETRY_SLEEP_MS / 1000) * @as(u32, 1000);
1143+
}
11291144
return BadHttpStatus.MaybeSpurious;
11301145
} else if (status != .ok) {
11311146
return BadHttpStatus.NonSpurious;
@@ -1134,6 +1149,94 @@ fn initHttpResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer:
11341149
resource.http_request.decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
11351150
}
11361151

1152+
/// Iterate through response headers to find Retry-After header, and parse the
1153+
/// value to return a value that represents how many seconds from now to wait
1154+
/// until a retry may be attempted.
1155+
///
1156+
/// If the header does not exist, `null` is returned. If the header value is
1157+
/// invalid, an error is returned to indicate that parsing failed.
1158+
///
1159+
/// Note that the implementation of this method in Cargo only respects this
1160+
/// header for 503 and 429 status codes, but the specification on MDN does not
1161+
/// restrict this header's usage to only those two status codes, so we won't
1162+
/// either.
1163+
///
1164+
/// For more information, see the MDN documentation:
1165+
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
1166+
fn parseRetryAfter(response: *const std.http.Client.Response) !?u32 {
1167+
var iter = response.head.iterateHeaders();
1168+
const retry_after: []const u8 = while (iter.next()) |header| {
1169+
if (ascii.eqlIgnoreCase(header.name, "retry-after")) {
1170+
break header.value;
1171+
}
1172+
} else {
1173+
return null;
1174+
};
1175+
1176+
// Option 1: The value is a positive integer indicating number of seconds
1177+
// to wait before retrying.
1178+
if (std.fmt.parseInt(u32, retry_after, 10) catch null) |value| {
1179+
return value;
1180+
}
1181+
1182+
// Option 2: The value is a timestamp that we need to parse, then use with
1183+
// current time to calculate how many seconds to wait before retrying.
1184+
//
1185+
// Parsing based on this specification:
1186+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Date
1187+
const RETRY_AFTER_LEN: usize = 29;
1188+
if (retry_after.len != RETRY_AFTER_LEN) {
1189+
return error.InvalidHeaderValueLength;
1190+
}
1191+
1192+
// Much more memory compact than an array of string slices, because
1193+
// pointers are large. Also 12 strings means 11 more `\0` bytes than we
1194+
// actually need.
1195+
const months = "JanFebMarAprMayJunJulAugSepOctNovDec";
1196+
1197+
const epoch = std.time.epoch;
1198+
1199+
const year = try std.fmt.parseInt(epoch.Year, retry_after[12..16], 10);
1200+
const month: epoch.Month = for (0..12) |i| {
1201+
const month = months[i * 3 .. (i * 3) + 3];
1202+
if (std.mem.eql(u8, month, retry_after[8..11])) {
1203+
break @enumFromInt(i + 1);
1204+
}
1205+
} else {
1206+
return error.CannotFindMonth;
1207+
};
1208+
const day = try std.fmt.parseInt(epoch.Day, retry_after[5..7], 10);
1209+
const hour = try std.fmt.parseInt(epoch.Hour, retry_after[17..19], 10);
1210+
const minute = try std.fmt.parseInt(epoch.Minute, retry_after[20..22], 10);
1211+
const second = try std.fmt.parseInt(epoch.Second, retry_after[23..25], 10);
1212+
1213+
const datetime = epoch.Datetime{
1214+
.year = year,
1215+
.month = month,
1216+
.day = day,
1217+
.hour = hour,
1218+
.minute = minute,
1219+
.second = second,
1220+
};
1221+
const datetime_epoch = try datetime.asEpochSeconds();
1222+
const timestamp = std.time.timestamp();
1223+
1224+
// We cannot support Retry-After if our system time is before epoch, but
1225+
// it is technically not an error, so return `null`.
1226+
if (timestamp < 0) {
1227+
return null;
1228+
}
1229+
1230+
const now = @as(u64, @intCast(std.time.timestamp()));
1231+
1232+
// If Retry-After is before now, no delay is needed
1233+
if (datetime_epoch.secs <= now) {
1234+
return 0;
1235+
}
1236+
1237+
return @as(u32, @intCast(datetime_epoch.secs - now));
1238+
}
1239+
11371240
fn initGitResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer: []u8) !void {
11381241
const eb = &f.error_bundle;
11391242

0 commit comments

Comments
 (0)