http_retry

    Deterministic HTTP retry decisions with Retry-After and bounded time budgets

    http
    retry
    retry-after
    backoff
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    24

    No dependencies

    #http_retry

    Pure HTTP retry decisions for MoonBit: Wait(milliseconds) or a typed reason to stop, given a response, replay safety, attempt count, and remaining time. No network, sleeps, randomness, or system clock inside the library. Apache-2.0; AI-assisted implementation.

    #Run from source

    Install the MoonBit toolchain, then:

    git clone https://github.com/sundaysebasidian-byte/moon-http-retry.git cd moon-http-retry moon check --target all moon test --target wasm-gc moon test --target js moon run cmd/main --target wasm-gc

    Output:

    rate-limited GET: wait 5000 ms deduplicated POST: wait 200 ms deadline exhausted: stop

    The three scenarios in cmd/main/main.mbt cover a rate-limited data fetch, a server-deduplicated POST, and a request whose deadline cannot accommodate Retry-After. They simulate complete policy inputs; they do not send HTTP. Registry publication is pending; the commands above work from source.

    After publication, add sebastian/http_retry and import it as @retry:

    let decision = @retry.decide_response(
    @retry.defaults(),
    http_method="GET", status=429, retry_index=0, elapsed_ms=100L,
    body_replayable=true, retry_after_values=["5"], now_seconds=0L,
    sample_ppm=0,
    )
    // Wait(5000L): zero jitter cannot shorten the server minimum.

    #Contract

    All durations are Int64 milliseconds. Only now_seconds is Unix wall-clock seconds. Use a monotonic clock for elapsed_ms, measured from the initial request. retry_index=0 is the first retry. max_retries excludes the initial request, and accepts 0..1000. Defaults: 3 retries, 200 ms base, 30 s delay cap, 120 s total budget, 1 s request reserve.

    • Status defaults: 408, 429, 500, 502, 503, 504. A retryable status alone does not establish that repeating a particular operation is safe.
    • GET, HEAD, PUT, DELETE, OPTIONS and TRACE are classified as idempotent. Names are case-sensitive. Every retry also needs body_replayable=true.
    • Other methods require replay_safe=true. Set it only when the server actually implements deduplication or equivalent semantics. A client-generated key alone does not suffice. Reuse the same key and exact body.
    • Exponential backoff saturates before overflow. sample_ppm in 0..1,000,000 scales the client delay down. Default 1,000,000 means no jitter. Supply independent uniform samples for full jitter.
    • Effective delay is max(client_delay, server_delay). A server minimum above the cap stops the retry; it is never clipped or reduced by jitter.
    • Wait plus request reserve must be strictly less than remaining budget. The caller must recheck the deadline after waking and enforce timeout and cancellation. The reserve is an estimate, not a runtime timeout.

    decide takes a parsed server delay. decide_response takes an array of raw, uncombined Retry-After values. Missing header means no server minimum; duplicates or unsupported syntax stop the retry. Earlier invalid input, status, replay, attempt or baseline budget failures take precedence.

    retry_after_delay accepts unsigned ASCII delta-seconds or strict IMF-fixdate, trims outer SP/HTAB, rejects overflow and inputs over 1024 UTF-16 code units, and returns zero for dates in the past. Wall time must lie in years 1601..9999. Weekday and Gregorian calendar are validated. Obsolete HTTP date formats, leap seconds and comma-combined headers are deliberately unsupported. This is not a complete RFC 9110 HTTP-date receiver implementation. Clock skew can affect absolute dates; supply a trusted clock or caller-adjusted time.

    Transport errors, redirects, credential refresh, stream replay, cancellation, distributed rate limiting and circuit breaking remain HTTP adapter concerns.

    #Ecosystem scope

    Generic retry executors already exist in MoonBit, including async retry utilities and moonresilience. This package focuses on the combination of HTTP server minimum delays, request replay safety, and a shared time budget, exposed as pure decisions for different HTTP clients. It does not claim that no other retry package exists. Competition eligibility and ecosystem overlap still need independent review.

    Semantics are informed by RFC 9110, particularly sections 9.2.2 and 10.2.3. Date conversion uses direct Gregorian year/month arithmetic.

    #Verification

    #Local HTTP simulation

    With Node.js 18+ and the MoonBit toolchain on PATH, run:

    node --test scripts/http-simulation-server.test.mjs node scripts/simulate-http.mjs

    The harness builds cmd/simulate-policy for JavaScript, sends real HTTP requests to a temporary server bound only to 127.0.0.1, and passes each failed response to this library's compiled decide_response. It performs actual waits and asserts server-observed request counts, minimum intervals, and side effects. The Node.js adapter handles I/O, raw headers, request deadlines, and rechecking the budget after waking; retry decisions remain in MoonBit.

    Twelve scenarios cover 429 recovery, 503 exponential backoff, unsafe and server-deduplicated POSTs, exhausted budgets and retries, malformed/duplicate Retry-After, excessive server delays, HTTP-date delays, 404, and non-replayable bodies. A negative control demonstrates that forced unsafe replay really does duplicate the simulated operation. Add --report results.json to save results. The server closes after each scenario. This is controlled integration evidence, not production load testing or validation against an external API.

    #Compiler and unit checks

    pwsh -File verify.ps1 checks all compiler targets, builds/tests WASM-GC and JS, and runs examples on both. Tests cover overflow, malformed headers, unsafe replay, deadline boundaries, and 288 date vectors independently generated by .NET DateTimeOffset. Regenerate with pwsh -File scripts/generate-date-vectors.ps1, then moon fmt. Tests need no clock, network service or secret.

    Before contributing, run moon info and moon fmt. pkg.generated.mbti records the exported API. Initial version 0.1.0 was tested with moon 0.1.20260920 / moonc 0.10.14+7d59c7ec9. CI uses the current stable toolchain and records versions; it is not a compiler-reproducible lock. Native and WASM receive static checks; runtime verification covers WASM-GC and JS.

    Decision

    pub(all) enum Decision {
    Stop(StopReason)
    Wait(Int64)
    } derive(Eq,
    Debug
    )

    Decision::equal

    fn Decision::equal(Decision, Decision) -> Bool

    Decision::not_equal

    fn Decision::not_equal(x : Decision, y : Decision) -> Bool

    Decision::to_repr

    Settings

    pub(all) struct Settings {
    max_retries : Int
    base_delay_ms : Int64
    max_delay_ms : Int64
    total_budget_ms : Int64
    request_reserve_ms : Int64
    } derive(Eq,
    Debug
    )

    Milliseconds throughout. max_retries excludes the initial request.

    Settings::equal

    fn Settings::equal(Settings, Settings) -> Bool

    Settings::not_equal

    fn Settings::not_equal(x : Settings, y : Settings) -> Bool

    Settings::to_repr

    StopReason

    pub(all) enum StopReason {
    InvalidSettings
    InvalidInput
    NotRetryable
    NotReplayable
    AttemptsExhausted
    BudgetExhausted
    ServerDelayExceedsCap
    InvalidRetryAfter
    } derive(Eq,
    Debug
    )

    StopReason::equal

    fn StopReason::equal(StopReason, StopReason) -> Bool

    StopReason::not_equal

    fn StopReason::not_equal(x : StopReason, y : StopReason) -> Bool

    backoff_ceiling

    fn backoff_ceiling(settings : Settings, attempt : Int) -> Int64?

    attempt=0 is the first retry. Returns None for invalid input/settings. Growth saturates before multiplication, including near Int64::MAX.

    decide

    fn decide(settings : Settings, http_method~ : String, status~ : Int, retry_index~ : Int, elapsed_ms~ : Int64, body_replayable~ : Bool, replay_safe? : Bool, server_delay_ms? : Int64, sample_ppm? : Int) -> Decision

    retry_index is zero-based, elapsed_ms must use a monotonic clock. replay_safe opts non-idempotent methods in only when the caller knows that the server implements deduplication or equivalent replay-safe semantics.

    decide_response

    fn decide_response(settings : Settings, http_method~ : String, status~ : Int, retry_index~ : Int, elapsed_ms~ : Int64, body_replayable~ : Bool, retry_after_values~ : Array[String], now_seconds~ : Int64, replay_safe? : Bool, sample_ppm? : Int) -> Decision

    Plan one response, taking uncombined Retry-After values. Zero fields means no server minimum; multiple fields or unsupported syntax stop the retry. now_seconds is wall time; elapsed_ms is monotonic elapsed time.

    defaults

    fn defaults() -> Settings

    idempotent_method

    fn idempotent_method(http_method : String) -> Bool

    HTTP methods are case-sensitive. Body replayability is checked separately.

    jitter_delay

    fn jitter_delay(ceiling_ms : Int64, sample_ppm : Int) -> Int64?

    Apply caller-provided full-jitter sample in 0..1_000_000, inclusive. Splitting quotient/remainder avoids overflow without floating point.

    parse_http_date

    fn parse_http_date(value : String) -> Int64?

    Parses a strict IMF-fixdate HTTP-date string (RFC 9110 Section 5.6.7 / RFC 7231 Section 7.1.1.1) into a Unix timestamp (seconds since 1970-01-01T00:00:00Z).

    Expected exact format: Sun, 06 Nov 1994 08:49:37 GMT
    • Length must be exactly 29 characters.
    • ASCII case sensitive.
    • Exact punctuation (comma, spaces, colons, "GMT").

    Validation rules:
    • Gregorian calendar years in range 1601..9999.
    • Month and day valid according to Gregorian calendar and leap years.
    • Time fields: hour 00..23, minute 00..59, second 00..59.
    • Weekday must match the calculated Gregorian day of week.

    Limitations:
    • Pure computation with zero IO, system clock access, or external dependencies.
    • Obsolete formats (RFC 850 / RFC 1036 and ANSI C asctime) are not supported.
    • Leap seconds (second 60) are deliberately rejected by this strict parser.
    • Years before 1601 or after 9999 are rejected.

    retry_after_delay

    fn retry_after_delay(value : String, now_seconds : Int64) -> Int64?

    Parse one Retry-After field value into milliseconds. Only SP/HTAB are trimmed. Strict IMF-fixdate or ASCII delta-seconds; no comma lists. now_seconds must be a Unix wall-clock timestamp in years 1601..9999. Dates in the past produce zero; overflow and malformed input produce None.

    retryable_status

    fn retryable_status(status : Int) -> Bool

    Conservative defaults for transient HTTP responses, not transport failures.

    valid_settings

    fn valid_settings(settings : Settings) -> Bool

    Pure settings validation, also performed by each decision.