-module(aws@retry). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/aws/retry.gleam"). -export([standard/0, standard_with/5, adaptive/1, adaptive_with/6, with_max_attempts/2, with_base_delay_ms/2, with_max_delay_ms/2, exponential_backoff/2, classify/3, with_retry/2]). -export_type([decision/0, strategy/0, gate_outcome/0, status_kind/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC( " Retry middleware. Wraps any `http_send.Send` with the retry semantics\n" " the AWS Smithy runtime ships\n" " (aws-sdk-rust/.../client/retries/strategy/standard.rs).\n" "\n" " Behaviour mirrored from upstream:\n" "\n" " - Exponential backoff with full jitter:\n" " `min(initial * 2^attempt, max) * rand()`.\n" " Matches `calculate_exponential_backoff` in Rust.\n" " - Status-code classifier: 2xx success; 4xx (except 408/429) is\n" " non-retryable; 408/429/5xx retryable; transport errors classified\n" " as `TransientError` (timeout-class) and charged at the higher\n" " `timeout_retry_cost`. Per AWS-Smithy `ErrorKind` mapping.\n" " - `Retry-After` header (integer seconds) overrides the computed\n" " backoff and is clamped to `max_delay`.\n" " - Token bucket gates retries (`try_acquire(cost)`); each retry holds\n" " a permit across attempts that is released on success / final\n" " non-retryable outcome, or replaced before the next retry. Matches\n" " Rust's `set_retry_permit` / `release_retry_permit`.\n" "\n" " **Deliberately out of scope** (explicit deltas vs Rust SDK, documented\n" " for the M4 audit):\n" "\n" " - Time-based bucket refill (`refill_rate` in Rust SDK). Our bucket\n" " only changes on acquire/release/reward.\n" " - Pre-request rate limiter gating\n" " (`should_attempt_initial_request`). Only retries are gated.\n" " - The CUBIC `ClientRateLimiter` — Rust ships this as a separate\n" " component for \"true\" adaptive mode. Our `adaptive` builder\n" " installs the token bucket only; CUBIC is a follow-up milestone.\n" ). -type decision() :: stop | {retry_after, integer(), integer()} | give_up. -opaque strategy() :: {strategy, integer(), integer(), integer(), fun((integer()) -> nil), fun(() -> float()), gleam@option:option(aws@internal@retry@rate_limiter:bucket())}. -type gate_outcome() :: no_bucket | {got_permit, aws@internal@retry@rate_limiter:permit()} | bucket_empty. -type status_kind() :: not_retryable | retryable. -file("src/aws/retry.gleam", 84). -spec standard() -> strategy(). standard() -> {strategy, 3, 100, 20000, fun gleam_erlang_ffi:sleep/1, fun aws_ffi:random_float/0, none}. -file("src/aws/retry.gleam", 95). -spec standard_with( integer(), integer(), integer(), fun((integer()) -> nil), fun(() -> float()) ) -> strategy(). standard_with(Max_attempts, Base_delay_ms, Max_delay_ms, Sleep, Rng) -> {strategy, Max_attempts, Base_delay_ms, Max_delay_ms, Sleep, Rng, none}. -file("src/aws/retry.gleam", 114). ?DOC( " Standard retry with a token-bucket gate. Equivalent to Rust SDK\n" " \"adaptive\" minus the CUBIC client rate limiter (see module docs).\n" ). -spec adaptive(aws@internal@retry@rate_limiter:bucket()) -> strategy(). adaptive(Bucket) -> {strategy, 3, 100, 20000, fun gleam_erlang_ffi:sleep/1, fun aws_ffi:random_float/0, {some, Bucket}}. -file("src/aws/retry.gleam", 125). -spec adaptive_with( aws@internal@retry@rate_limiter:bucket(), integer(), integer(), integer(), fun((integer()) -> nil), fun(() -> float()) ) -> strategy(). adaptive_with(Bucket, Max_attempts, Base_delay_ms, Max_delay_ms, Sleep, Rng) -> {strategy, Max_attempts, Base_delay_ms, Max_delay_ms, Sleep, Rng, {some, Bucket}}. -file("src/aws/retry.gleam", 153). ?DOC( " Override the per-request max attempt count on an existing\n" " `Strategy`, preserving the other knobs (delays, sleep / rng /\n" " rate-limiter). The common case for tuning retry behaviour —\n" " callers usually want the AWS-recommended backoff curve but a\n" " different attempt budget (1 for fail-fast tests, 5 for\n" " long-running batch workloads, etc.).\n" "\n" " Strategy is opaque so this setter lives here; the runtime\n" " re-exports a `with_max_attempts(config, n)` convenience that\n" " threads through to this setter.\n" ). -spec with_max_attempts(strategy(), integer()) -> strategy(). with_max_attempts(Strategy, Max_attempts) -> {strategy, Max_attempts, erlang:element(3, Strategy), erlang:element(4, Strategy), erlang:element(5, Strategy), erlang:element(6, Strategy), erlang:element(7, Strategy)}. -file("src/aws/retry.gleam", 162). ?DOC( " Override the initial-retry-backoff base on an existing\n" " `Strategy`. The first retry sleeps roughly `base_delay_ms` with\n" " full jitter; each subsequent attempt doubles the cap up to\n" " `max_delay_ms`. Set to 0 (paired with `with_max_delay_ms(0)`)\n" " for tests that want zero-delay retries.\n" ). -spec with_base_delay_ms(strategy(), integer()) -> strategy(). with_base_delay_ms(Strategy, Base_delay_ms) -> {strategy, erlang:element(2, Strategy), Base_delay_ms, erlang:element(4, Strategy), erlang:element(5, Strategy), erlang:element(6, Strategy), erlang:element(7, Strategy)}. -file("src/aws/retry.gleam", 170). ?DOC( " Override the maximum-backoff cap on an existing `Strategy`.\n" " Pairs with `with_base_delay_ms` for tests that need zero or\n" " very short delays; production callers usually keep the AWS-\n" " recommended default.\n" ). -spec with_max_delay_ms(strategy(), integer()) -> strategy(). with_max_delay_ms(Strategy, Max_delay_ms) -> {strategy, erlang:element(2, Strategy), erlang:element(3, Strategy), Max_delay_ms, erlang:element(5, Strategy), erlang:element(6, Strategy), erlang:element(7, Strategy)}. -file("src/aws/retry.gleam", 354). -spec release_if_held( strategy(), gleam@option:option(aws@internal@retry@rate_limiter:permit()) ) -> nil. release_if_held(Strategy, Permit) -> case {erlang:element(7, Strategy), Permit} of {{some, Bucket}, {some, P}} -> aws@internal@retry@rate_limiter:release(Bucket, P); {_, _} -> nil end. -file("src/aws/retry.gleam", 343). -spec gate(strategy(), integer()) -> gate_outcome(). gate(Strategy, Cost) -> case erlang:element(7, Strategy) of none -> no_bucket; {some, Bucket} -> case aws@internal@retry@rate_limiter:try_acquire(Bucket, Cost) of {acquired, P} -> {got_permit, P}; empty -> bucket_empty end end. -file("src/aws/retry.gleam", 361). -spec reward_if_present(strategy()) -> nil. reward_if_present(Strategy) -> case erlang:element(7, Strategy) of {some, Bucket} -> aws@internal@retry@rate_limiter:reward_success(Bucket); none -> nil end. -file("src/aws/retry.gleam", 333). -spec do_pow2(integer(), integer()) -> integer(). do_pow2(N, Acc) -> case N =< 0 of true -> Acc; false -> do_pow2(N - 1, Acc * 2) end. -file("src/aws/retry.gleam", 329). -spec pow2(integer()) -> integer(). pow2(N) -> do_pow2(N, 1). -file("src/aws/retry.gleam", 319). ?DOC( " Exponential backoff with full jitter, mirrored from Rust SDK\n" " `calculate_exponential_backoff`:\n" "\n" " raw = base * 2^(attempt-1)\n" " bound = min(raw, max)\n" " delay = rng() * bound\n" "\n" " `attempt` is 1-indexed (the first retry uses base * 2^0 = base).\n" ). -spec exponential_backoff(strategy(), integer()) -> integer(). exponential_backoff(Strategy, Attempt) -> Raw = erlang:element(3, Strategy) * pow2(Attempt - 1), Bound = gleam@int:min(Raw, erlang:element(4, Strategy)), Jittered = (erlang:element(6, Strategy))() * erlang:float(Bound), case erlang:round(Jittered) of N when N < 0 -> 0; N@1 -> N@1 end. -file("src/aws/retry.gleam", 264). -spec classify_transport_error( aws@internal@http_send:http_error(), integer(), strategy() ) -> decision(). classify_transport_error(_, Attempt, Strategy) -> case Attempt < erlang:element(2, Strategy) of true -> {retry_after, exponential_backoff(Strategy, Attempt), 10}; false -> give_up end. -file("src/aws/retry.gleam", 296). -spec retry_after_seconds(gleam@http@response:response(bitstring())) -> gleam@option:option(integer()). retry_after_seconds(Resp) -> case gleam@http@response:get_header(Resp, <<"retry-after"/utf8>>) of {ok, Value} -> case gleam_stdlib:parse_int(Value) of {ok, Secs} -> case Secs >= 0 of true -> {some, Secs}; false -> none end; {error, _} -> none end; {error, _} -> none end. -file("src/aws/retry.gleam", 288). ?DOC( " 408 and 429 are throttling-adjacent (request timeout / too many\n" " requests); 5xx is server fault. All map to the same retry cost class —\n" " matches the Rust SDK's `acquire(ErrorKind)` switch.\n" ). -spec classify_status(integer()) -> status_kind(). classify_status(Status) -> case Status of 408 -> retryable; 429 -> retryable; S when (S >= 500) andalso (S =< 599) -> retryable; _ -> not_retryable end. -file("src/aws/retry.gleam", 242). -spec classify_response( gleam@http@response:response(bitstring()), integer(), strategy() ) -> decision(). classify_response(Resp, Attempt, Strategy) -> case {classify_status(erlang:element(2, Resp)), Attempt < erlang:element(2, Strategy)} of {not_retryable, _} -> stop; {_, false} -> give_up; {_, true} -> Delay = case retry_after_seconds(Resp) of {some, Secs} -> gleam@int:min(Secs * 1000, erlang:element(4, Strategy)); none -> exponential_backoff(Strategy, Attempt) end, {retry_after, Delay, 5} end. -file("src/aws/retry.gleam", 231). ?DOC( " Classify one attempt's outcome. Exposed for test asserting and so the\n" " protocol-codec layer (M5) can override per-service error semantics.\n" ). -spec classify( {ok, gleam@http@response:response(bitstring())} | {error, aws@internal@http_send:http_error()}, integer(), strategy() ) -> decision(). classify(Result, Attempt, Strategy) -> case Result of {ok, Resp} -> classify_response(Resp, Attempt, Strategy); {error, Err} -> classify_transport_error(Err, Attempt, Strategy) end. -file("src/aws/retry.gleam", 179). -spec do_attempt( fun((gleam@http@request:request(bitstring())) -> {ok, gleam@http@response:response(bitstring())} | {error, aws@internal@http_send:http_error()}), strategy(), gleam@http@request:request(bitstring()), integer(), gleam@option:option(aws@internal@retry@rate_limiter:permit()) ) -> {ok, gleam@http@response:response(bitstring())} | {error, aws@internal@http_send:http_error()}. do_attempt(Send, Strategy, Req, Attempt, Held_permit) -> Result = Send(Req), case classify(Result, Attempt, Strategy) of stop -> release_if_held(Strategy, Held_permit), reward_if_present(Strategy), Result; give_up -> release_if_held(Strategy, Held_permit), Result; {retry_after, Delay, Cost} -> case gate(Strategy, Cost) of no_bucket -> (erlang:element(5, Strategy))(Delay), do_attempt(Send, Strategy, Req, Attempt + 1, none); {got_permit, New_permit} -> release_if_held(Strategy, Held_permit), (erlang:element(5, Strategy))(Delay), do_attempt( Send, Strategy, Req, Attempt + 1, {some, New_permit} ); bucket_empty -> release_if_held(Strategy, Held_permit), Result end end. -file("src/aws/retry.gleam", 175). ?DOC(" Wrap a `Send` with retry semantics.\n"). -spec with_retry( fun((gleam@http@request:request(bitstring())) -> {ok, gleam@http@response:response(bitstring())} | {error, aws@internal@http_send:http_error()}), strategy() ) -> fun((gleam@http@request:request(bitstring())) -> {ok, gleam@http@response:response(bitstring())} | {error, aws@internal@http_send:http_error()}). with_retry(Send, Strategy) -> fun(Req) -> do_attempt(Send, Strategy, Req, 1, none) end.