Sign in

    sdk-runtime

    API-agnostic client runtime for MoonBit SDKs: error taxonomy, Retry-After and backoff, tri-state JSON fields, open enums. Built on gaato/http; no async runtime dependency.

    sdk
    http
    retry
    rate-limit
    pagination
    json
    Download zip
    Author
    Version
    0.5.0
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    1K

    Dependencies

    #gaato/sdk-runtime

    API-agnostic client runtime for MoonBit SDKs, built on gaato/http: an error taxonomy (SdkError), backoff and retry policy, a Client that resolves URLs, applies default headers and auth, runs a rate limiter and middleware, classifies responses and retries (never resending a stream body a transport has read); a window rate limiter, a paginator, tri-state JSON fields (Presence), and open enums (gaato/sdk-runtime/json). The HTTP-generic parts it builds on (Retry-After parsing, multipart, randomness) live in gaato/http. No async runtime dependency: time comes through the Clock trait.

    Unofficial and experimental. Source, issues and design notes: https://github.com/gaato/mbt-sdk

    Inject a gate from gaato/http-async to serialize each bucket until its response is reported. An optional global window counts non-exempt admissions across buckets; both timing and synchronization remain injected.

    let limiter = @runtime.WindowLimiter::new(
    @adapter.AsyncClock::new(),
    @runtime.rate_limit_headers(remaining="x-ratelimit-remaining", reset_after_seconds="x-ratelimit-reset-after"),
    gate=() => @adapter.SemaphoreGate::new(),
    global={ limit: 50, window_ms: 1000 },
    )

    RateLimiter

    pub(open) trait RateLimiter {
    async fn acquire(Self, String, global_exempt~ : Bool) -> Unit
    async fn release(Self, String, status~ : Int, headers~ :
    Headers
    ) -> Unit
    }

    A per-bucket admission policy updated from HTTP responses.

    acquire and release come in pairs: the client calls release exactly once for every acquire that returned, with the response's status and headers, or with status 0 and no headers when the attempt produced no response (a transport failure or a cancellation). A limiter that holds per-bucket state across the exchange, such as a gate that serialises one bucket, relies on that pairing; a stateless window limiter ignores the status-0 calls.

    A cancelled task still gets its release, but inside cancellation any further asynchronous work is cut short, so an implementation that talks to another process must recover on its own if that call does not complete (dropping its connection so the peer can clean up, for example).

    RetryDecider

    pub(open) trait RetryDecider {
    fn next_delay_ms(Self, SdkError,
    Request
    , Int, Double) -> Int?
    }

    Decides, after a failed attempt, whether the client sends the request again and how long it waits first.

    RetryPolicy is the general-purpose implementation. An API with its own rules — one that must never resend a timed-out request because it may already have been processed, or that reads the delay from a response body — supplies its own implementation to Client::new.

    SdkError

    pub(all) suberror SdkError {
    Transport(
    HttpError
    )
    Status(status~ : Int, headers~ :
    Headers
    , body~ : Bytes)
    RateLimited(retry_after_ms~ : Int?, headers~ :
    Headers
    , body~ : Bytes)
    Decode(message~ : String, body~ : Bytes)
    Config(String)
    } derive(
    Debug
    )

    Failures distinguished by transport, HTTP status, decoding, or configuration.

    SdkError::is_retryable

    fn SdkError::is_retryable(self : SdkError) -> Bool

    Reports transient failures; request idempotency remains the caller's concern. Every transport failure but Protocol is transient, and a proxy's refusal to open a tunnel only when its status is 502, 503 or 504.

    SdkError::status

    fn SdkError::status(self : SdkError) -> Int?

    Returns an HTTP status only for failures with an HTTP status.

    SdkError::to_repr

    Formats the classified failure for debugging.

    Attempt

    pub(all) struct Attempt {
    bucket : String
    request :
    Request

    attempt : Int
    status : Int
    headers :
    Headers

    body : Bytes
    duration_ms : Int64
    error : SdkError?
    }

    One network attempt of a Client call, as reported to the observer.

    status, headers and body are those of the response (the body is empty for a streaming response, whose body belongs to the caller); an attempt that produced no response (a transport failure or a cancellation) reports status 0, empty headers and body, and the failure in error when there is one. attempt counts from 0, so a request that succeeded after two retries reports its last attempt as 2.

    Auth

    pub(all) enum Auth {
    NoAuth
    Bearer(String)
    Header(String, String)
    }

    Request authentication. Debug output always redacts credential values.
    impl Debug for Auth

    Auth::apply

    Adds authentication only when the request has no header with the same name.

    test {
    let request = @runtime.Bearer("secret").apply(@http.Request::get("/"))
    assert_eq(request.headers.get("authorization"), Some("Bearer secret"))
    }

    Auth::to_repr

    Redacted debug representation of authentication settings.

    Backoff

    pub(all) struct Backoff {
    base_ms : Int
    max_ms : Int
    factor : Double
    jitter : Double
    } derive(Eq,
    Debug
    )

    Parameters for pure exponential backoff with symmetric jitter.

    Backoff::default

    fn Backoff::default() -> Backoff

    Uses 500ms base, 30000ms cap, factor 2, and jitter 0.2.

    Backoff::delay_ms

    fn Backoff::delay_ms(self : Backoff, attempt : Int, random : Double) -> Int

    Computes a capped delay, truncating fractional milliseconds. Negative attempts behave as zero; random is clamped into [0, 1).

    test {
    assert_eq(@runtime.Backoff::default().delay_ms(2, 0.5), 2000)
    }

    Backoff::equal

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

    Compares backoff parameters.

    Backoff::not_equal

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

    Compares backoff parameters.

    Backoff::to_repr

    Formats backoff parameters for debugging.

    Client

    pub struct Client {
    // private fields
    }

    A transport-independent SDK client with authentication, retry, and limits.

    Client::new

    fn Client::new(transport : &
    Transport
    , clock : &
    Clock
    , base_url~ : String, default_headers? :
    Headers
    , auth? : Auth, retry? : &RetryDecider, limiter? : &RateLimiter, middleware? : Array[async (
    Request
    , async (
    Request
    ) ->
    Response
    raise
    HttpError
    ) ->
    Response
    raise
    HttpError
    ], observer? : (Attempt) -> Unit, random? : () -> Double) -> Client

    Creates a client from injected transport and clock implementations.

    retry decides after each failed attempt whether to send again; the default is RetryPolicy::default(). observer is called once per attempt, after the limiter's release and before that decision, including for an attempt that fails or is cancelled; it must not raise and should return promptly.

    Client::send

    async fn Client::send(self : Client, request :
    Request
    , bucket? : String, global_exempt? : Bool, body? : () -> Bytes) ->
    Response
    raise SdkError

    Sends a buffered request, retrying only as directed by the retry decider. A request whose stream body (Request::body_stream) a transport has read is never sent again, whatever the decider says: the failure is raised.

    bucket names the rate-limit bucket the request is admitted through and global_exempt keeps it out of an account-wide limit; both are passed to the limiter as they are.

    body, when given, replaces the request's body: it is called once per attempt, after the limiter admits that attempt, so a request waiting for admission holds no encoded body (a multipart upload can keep only the segments from @multipart.encode_segments and join them here). The retry decider sees the request without it; the observer sees what was sent.

    Client::send_json

    async fn Client::send_json(self : Client, request :
    Request
    , bucket? : String, global_exempt? : Bool, body? : () -> Bytes) -> Json raise SdkError

    Sends and decodes JSON; an empty successful body is JSON null. body is as for send.

    Client::send_stream

    async fn Client::send_stream(self : Client, request :
    Request
    , bucket? : String, global_exempt? : Bool, body? : () -> Bytes) -> (
    ResponseHead
    , &
    BodyStream
    ) raise SdkError

    Sends a streaming request. A successful body is returned to the caller; an unsuccessful body is read up to 1 MiB, closed, classified, and retried. body, and a stream request body, are as for send.

    GlobalWindow

    pub(all) struct GlobalWindow {
    limit : Int
    window_ms : Int
    } derive(Eq,
    Debug
    )

    An account-wide admission window counted across buckets: at most limit admissions in each window_ms. Both must be positive; a window with a non-positive limit or length imposes no limit, since it would otherwise block forever or reset on every admission.

    GlobalWindow::equal

    Equality for global windows.

    GlobalWindow::not_equal

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

    Equality for global windows.

    GlobalWindow::to_repr

    Debug representation of global windows.

    NoLimiter

    pub struct NoLimiter {
    }

    A limiter that never delays requests and ignores releases.

    NoLimiter::acquire

    async fn NoLimiter::acquire(_self : NoLimiter, _bucket : String, global_exempt~ : Bool) -> Unit

    Explicit no-op limiter methods.

    NoLimiter::new

    fn NoLimiter::new() -> NoLimiter

    Creates a limiter that admits every request.

    NoLimiter::release

    async fn NoLimiter::release(_self : NoLimiter, _bucket : String, status~ : Int, headers~ :
    Headers
    ) -> Unit

    Explicit no-op limiter methods.

    Page

    pub(all) struct Page[T] {
    items : Array[T]
    next : String?
    }

    One decoded page and its optional cursor for the following page, for Paginator::with_cursor.

    Paginator

    pub struct Paginator[T, E] {
    // private fields
    }

    A stateful async paginator.

    E is the error the fetch raises, so an SDK exposes its own error type through the paginator instead of SdkError.

    Paginator::collect

    async fn[T, E : Error] Paginator::collect(self : Paginator[T, E], max~ : Int) -> Array[T] raise E

    Collects at most max remaining items. The unused rest of the last page is kept for the next call, so nothing fetched is lost. A non-positive maximum performs no requests and returns an empty array.

    Paginator::each

    async fn[T, E : Error] Paginator::each(self : Paginator[T, E], f : async (T) -> Unit raise E) -> Unit raise E

    Visits every remaining item in page order.

    Paginator::new

    fn[T, E : Error] Paginator::new(fetch : async () -> Array[T]? raise E) -> Paginator[T, E]

    Creates a paginator over a fetch that owns its own cursor.

    fetch returns the next page, or None once the sequence has ended; an empty page ends it as well. The fetch keeps whatever cursor the API uses (a snowflake, a timestamp, a URL) and must advance it only after a page has arrived, so that a fetch that raised is asked for the same page again. For an API whose cursor is one string, with_cursor keeps that state here.

    Paginator::next_page

    async fn[T, E : Error] Paginator::next_page(self : Paginator[T, E]) -> Array[T]? raise E

    Fetches the next non-empty page, or None once the sequence has ended. Items that a previous collect did not use come back first.

    Paginator::with_cursor

    fn[T, E : Error] Paginator::with_cursor(fetch : async (String?) -> Page[T] raise E) -> Paginator[T, E]

    Creates a paginator over a string cursor. The first fetch receives None; each page names the cursor of the following one, and a page without a cursor is the last. A fetch that raised leaves the cursor unchanged.

    RateLimitState

    pub(all) struct RateLimitState {
    remaining : Int
    reset_at_unix_ms : Int64
    } derive(Eq,
    Debug
    )

    Remaining requests and the Unix millisecond instant when a window resets.

    RateLimitState::equal

    Equality for rate-limit states.

    RateLimitState::not_equal

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

    Equality for rate-limit states.

    RateLimitState::to_repr

    Debug representation of rate-limit states.

    RetryPolicy

    pub(all) struct RetryPolicy {
    max_retries : Int
    backoff : Backoff
    max_retry_after_ms : Int
    retry_non_idempotent : Bool
    } derive(Eq,
    Debug
    )

    Retry limits, backoff, Retry-After cap, and non-idempotent policy.

    RetryPolicy::default

    fn RetryPolicy::default() -> RetryPolicy

    Uses two retries, default backoff, a 60-second server-delay cap, and only retries non-idempotent requests when the failure proves they were not run.

    RetryPolicy::equal

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

    Equality for retry policy values.

    RetryPolicy::next_delay_ms

    fn RetryPolicy::next_delay_ms(self : RetryPolicy, error : SdkError, request :
    Request
    , attempt : Int, random : Double) -> Int?

    The policy's decision as a dot-callable method.

    RetryPolicy::none

    fn RetryPolicy::none() -> RetryPolicy

    Disables retries while retaining the other default settings.

    RetryPolicy::not_equal

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

    Equality for retry policy values.

    RetryPolicy::to_repr

    Debug representation of retry policy values.

    WindowLimiter

    pub struct WindowLimiter {
    // private fields
    }

    A mutable per-bucket fixed-window limiter driven by an injected clock.

    WindowLimiter::acquire

    async fn WindowLimiter::acquire(self : WindowLimiter, bucket : String, global_exempt~ : Bool) -> Unit

    Explicit fixed-window limiter methods.

    WindowLimiter::new

    fn WindowLimiter::new(clock : &
    Clock
    , parse : (
    Headers
    , Int64) -> RateLimitState?, gate? : () -> &
    Gate
    , global? : GlobalWindow, global_block? : (
    Headers
    ) -> Bool, default_retry_after_ms? : Int) -> WindowLimiter

    Creates a limiter using an API-specific response-header parser. gate creates a bucket's gate, held from acquire until the paired release; a bucket nobody holds or waits on forgets its gate and gets a new one on its next acquire. global limits non-exempt admissions across buckets; one with a non-positive limit or length is ignored. global_block identifies account-wide 429 responses. default_retry_after_ms supplies a delay when a 429 has no Retry-After. Negative fallback delays are treated as zero. A response's parsed reset and Retry-After count only up to one hour ahead, so no server can hold a bucket, or every request, longer than that.

    WindowLimiter::release

    async fn WindowLimiter::release(self : WindowLimiter, bucket : String, status~ : Int, headers~ :
    Headers
    ) -> Unit

    Explicit fixed-window limiter methods.

    WindowLimiter::state

    fn WindowLimiter::state(self : WindowLimiter, bucket : String) -> RateLimitState?

    Returns the currently recorded state for a bucket.

    classify

    fn classify(response :
    Response
    , now_unix_ms? : Int64) ->
    Response
    raise SdkError

    Returns successful responses unchanged and classifies all other statuses. The error keeps at most the first 1 MiB of the body, as a streaming error body is read; the status and headers are kept whole.

    test {
    let response : @http.Response = {
    status: 204,
    headers: @http.Headers::new(),
    body: b"",
    }
    assert_eq(@runtime.classify(response), response)
    let error = @runtime.Transport(@http.Timeout(phase=Head, ms=500))
    assert_eq(error.status(), None)
    assert_true(error.is_retryable())
    }

    default_random

    #deprecated("moved to gaato/http/random: use @random.default_random")
    fn default_random() -> Double

    Returns a value in [0, 1), using host randomness or a deterministic xorshift64 fallback. The fallback is intended for jitter and boundaries, not security.

    is_idempotent

    #deprecated("moved to gaato/http: use @http.is_idempotent")
    fn is_idempotent(request :
    Request
    ) -> Bool

    Reports whether the method is idempotent or an idempotency key is present.
    #deprecated("moved to gaato/http: use @http.parse_link_next")
    fn parse_link_next(headers :
    Headers
    ) -> String?

    Returns the target of the first RFC 8288 Link value whose relation includes next, across repeated headers and comma-separated link values.

    parse_retry_after

    #deprecated("moved to gaato/http: use @http.parse_retry_after")
    fn parse_retry_after(value : String, now_unix_ms? : Int64) -> Int?

    Parses nonnegative seconds (including decimals) or an IMF-fixdate with a clock. Results are truncated to milliseconds and saturated at Int max.

    test {
    assert_eq(@http.parse_retry_after("1.2349"), Some(1234))
    }

    rate_limit_headers

    fn rate_limit_headers(remaining~ : String, reset_after_seconds? : String, reset_unix_seconds? : String) -> ((
    Headers
    , Int64) -> RateLimitState?)

    Builds a parser for conventional remaining/reset response headers.

    retry_after_ms

    #deprecated("moved to gaato/http: use @http.retry_after_ms")
    fn retry_after_ms(headers :
    Headers
    , now_unix_ms? : Int64) -> Int?

    Prefers the first retry-after-ms value; falls back to retry-after when that header is absent or unparseable, as the official OpenAI SDKs do.