/** * Backoff arithmetic and `Retry-After` parsing. * * Internal to the package. */ module cepis.retry; import core.time : Duration, dur; import std.array : split; import std.conv : to; import std.datetime.date : DateTime; import std.datetime.systime : Clock, SysTime; import std.datetime.timezone : UTC; import std.math : isFinite; import std.random : uniform01; import std.string : indexOf, strip; import std.typecons : Nullable, nullable; package @safe: /** * A random $(D Duration) in `[0, ceiling]` — $(I 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. * * The source is the default thread-local generator, which is plenty for * spreading retries and is not suitable for anything cryptographic. */ Duration jitter(Duration ceiling) { if (ceiling <= Duration.zero) return Duration.zero; return dur!"hnsecs"(cast(long)(ceiling.total!"hnsecs" * uniform01())); } /** * Parse the `Retry-After` header in both RFC 9110 forms. * * 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. * * A date already in the past yields zero; anything unparseable yields null. */ Nullable!Duration parseRetryAfter(scope const(char)[] value) { const text = value.strip(); if (text.length == 0) return Nullable!Duration.init; try { const seconds = text.to!double; if (isFinite(seconds)) return nullable(dur!"msecs"(seconds <= 0 ? 0 : cast(long)(seconds * 1000))); } catch (Exception) { } const target = httpDateToUnix(text); if (target.isNull) return Nullable!Duration.init; const delta = target.get - Clock.currTime(UTC()).toUnixTime(); return nullable(dur!"seconds"(delta <= 0 ? 0 : delta)); } /** * 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. */ private Nullable!long httpDateToUnix(scope const(char)[] value) { auto rest = value; const comma = value.indexOf(','); if (comma >= 0) rest = value[comma + 1 .. $]; const parts = rest.strip().split(); if (parts.length < 4) return Nullable!long.init; const month = monthNumber(parts[1]); if (month == 0) return Nullable!long.init; const clock = parts[3].split(':'); if (clock.length != 3) return Nullable!long.init; try { const stamp = SysTime(DateTime(parts[2].to!int, month, parts[0].to!int, clock[0].to!int, clock[1].to!int, clock[2].to!int), UTC()); return nullable(stamp.toUnixTime()); } catch (Exception) { return Nullable!long.init; } } private int monthNumber(scope const(char)[] name) pure nothrow @nogc { static immutable string[12] months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]; foreach (index, month; months) if (month == name) return cast(int)(index + 1); return 0; } @("plain seconds are read") unittest { assert(parseRetryAfter(" 7 ").get == dur!"seconds"(7)); } @("negative seconds become zero") unittest { assert(parseRetryAfter("-5").get == Duration.zero); } @("an http date is understood") unittest { assert(httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT").get == 1_792_567_680L); } @("a date in the past is zero") unittest { assert(parseRetryAfter("Thu, 01 Jan 1970 00:00:00 GMT").get == Duration.zero); } @("a date in the future is a real wait") unittest { assert(parseRetryAfter("Wed, 21 Oct 2099 07:28:00 GMT").get > dur!"days"(1)); } @("nonsense is rejected") unittest { assert(parseRetryAfter("soon").isNull); assert(parseRetryAfter("").isNull); assert(parseRetryAfter("Wed, 99 Foo 2026 07:28:00 GMT").isNull); } @("jitter stays within the ceiling") unittest { const ceiling = dur!"msecs"(50); foreach (_; 0 .. 200) { const wait = jitter(ceiling); assert(wait >= Duration.zero && wait <= ceiling); } assert(jitter(Duration.zero) == Duration.zero); }