Skip to content

Commit f984f60

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

File tree

1 file changed

+110
-8
lines changed

1 file changed

+110
-8
lines changed

src/Package/Fetch.zig

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -333,21 +333,28 @@ 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())));
345-
const rand = prng.random();
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+
}
346352
if (r.cur_retries == 0) {
353+
var prng = std.Random.DefaultPrng.init(@as(u64, @bitCast(std.time.microTimestamp())));
354+
const rand = prng.random();
347355
return rand.intRangeAtMost(u32, MIN_RETRY_JITTER_MS, MAX_RETRY_JITTER_MS);
348-
} else {
349-
return @min(r.cur_retries * 3 * 1000, MAX_RETRY_SLEEP_MS);
350356
}
357+
return @min(r.cur_retries * 3 * 1000, MAX_RETRY_SLEEP_MS);
351358
}
352359

353360
fn callWithRetries(
@@ -358,8 +365,8 @@ pub const Retry = struct {
358365
) @typeInfo(FunctionType).@"fn".return_type.? {
359366
while (true) {
360367
return @call(.auto, callback, args) catch |err| {
361-
if (maybe_spurious(err) and r.cur_retries < r.max_retries) {
362-
std.Thread.sleep(std.time.ns_per_ms * r.retryDelayMs());
368+
if (maybeSpurious(err) and r.cur_retries < r.max_retries) {
369+
std.Thread.sleep(std.time.ns_per_ms * @as(u64, r.calcRetryDelayMs()));
363370
r.cur_retries += 1;
364371
continue;
365372
}
@@ -374,7 +381,7 @@ const BadHttpStatus = error{
374381
NonSpurious,
375382
};
376383

377-
fn maybe_spurious(err: anyerror) bool {
384+
fn maybeSpurious(err: anyerror) bool {
378385
return switch (err) {
379386
// tcp errors
380387
std.http.Client.ConnectTcpError.ConnectionTimedOut,
@@ -1126,6 +1133,13 @@ fn initHttpResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer:
11261133
const status = response.head.status;
11271134

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

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

0 commit comments

Comments
 (0)