summaryrefslogtreecommitdiffstats
path: root/source/cepis/retry.d
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:47:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:47:38 -0300
commit794f28199a4dde9d18ec711864336c96c58bb422 (patch)
tree3985f1c033a9c62a264829cd354da9ce214e5db7 /source/cepis/retry.d
downloadd-794f28199a4dde9d18ec711864336c96c58bb422.tar.gz
Initial importv0.1.0
Diffstat (limited to 'source/cepis/retry.d')
-rw-r--r--source/cepis/retry.d170
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);
+}