diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:47:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:47:38 -0300 |
| commit | 794f28199a4dde9d18ec711864336c96c58bb422 (patch) | |
| tree | 3985f1c033a9c62a264829cd354da9ce214e5db7 /source/cepis/retry.d | |
| download | d-794f28199a4dde9d18ec711864336c96c58bb422.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'source/cepis/retry.d')
| -rw-r--r-- | source/cepis/retry.d | 170 |
1 files changed, 170 insertions, 0 deletions
diff --git a/source/cepis/retry.d b/source/cepis/retry.d new file mode 100644 index 0000000..dd3ac77 --- /dev/null +++ b/source/cepis/retry.d @@ -0,0 +1,170 @@ +/** + * 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); +} |