///|
/// Client orchestration: build the HTTP call, classify the response, and
/// drive retries through the verified policy in `retry.mbt`. Both a
/// blocking and a callback-based asynchronous entry point are provided.
///|
/// Client configuration. Keep `api_key` server-side in production web apps;
/// shipping it to a browser exposes it to users.
pub(all) struct ClientConfig {
api_key : String
model : String
base_url : String
retry : RetryPolicy
} derive(Debug)
///|
pub extend ClientConfig with @moonbitlang/core/debug.Debug::{to_repr}
///|
/// Create a config with defaults: `jev-latest`, `https://api.typesafe.ai`,
/// and the default retry policy.
pub fn ClientConfig::make(
api_key~ : String,
model? : String = "jev-latest",
base_url? : String = "https://api.typesafe.ai",
retry? : RetryPolicy = RetryPolicy::defaults(),
) -> ClientConfig {
{ api_key, model, base_url, retry, }
}
///|
/// Whether an SdkError is transient and worth retrying: network failures,
/// 429 rate limits, and 5xx server errors. Validation and decode errors are
/// not retryable.
pub fn is_retryable(err : SdkError) -> Bool {
match err {
Network(_) => true
RateLimited(_) => true
Server(_, _) => true
InvalidRequest(_, _) => false
Decode(_) => false
}
}
///|
fn retry_after_secs_of(err : SdkError) -> Int {
match err {
RateLimited(Some(secs)) => secs
_ => -1
}
}
///|
/// Classify an HTTP response into a decoded SystemOneResponse or an
/// SdkError: 200 decodes, 429 extracts Retry-After (seconds or HTTP-date,
/// resolved against `now_secs`), 5xx is retryable, other statuses are
/// invalid requests.
pub fn classify_response(
response : HttpResponse,
now_secs : Int,
) -> Result[SystemOneResponse, SdkError] {
if response.status == 200 {
decode_response(response.body)
} else if response.status == 429 {
Err(
RateLimited(
parse_retry_after(response.headers.get("retry-after"), now_secs),
),
)
} else if response.status >= 500 {
Err(Server(response.status, response.body))
} else {
Err(InvalidRequest(response.status, response.body))
}
}
///|
/// Build the outbound HTTP request for a System One call: bearer auth,
/// JSON content type, and the TypeSafe-Danger-Zone-Images header whenever
/// the request carries image assets.
pub fn build_http_request(
config : ClientConfig,
request : SystemOneRequest,
) -> HttpRequest {
let headers : Map[String, String] = Map([
("authorization", "Bearer " + config.api_key),
("content-type", "application/json"),
])
if request.assets.length() > 0 {
headers.set("typesafe-danger-zone-images", "true")
}
{
url: config.base_url + "/v1/systemone",
http_method: "POST",
headers,
body: encode_request_to_string(request),
}
}
///|
/// Perform exactly one evaluate call through a blocking transport.
pub fn evaluate_once(
config : ClientConfig,
transport : Transport,
request : SystemOneRequest,
now_secs : () -> Int,
) -> Result[SystemOneResponse, SdkError] {
let http_request = build_http_request(config, request)
match (transport.deliver)(http_request) {
Err(NetworkFailure(message)) => Err(Network(message))
Ok(response) => classify_response(response, now_secs())
}
}
///|
/// Evaluate with retries over a blocking transport. `sleep_ms` and
/// `now_secs` are injected so tests run with zero waiting, a fixed clock,
/// and full observability; a real app passes its own timer and clock.
/// Retries follow the verified policy: a Retry-After directive (seconds or
/// HTTP-date) is honored exactly, otherwise exponential backoff capped at
/// `max_delay_ms`.
pub fn evaluate_with_retries(
config : ClientConfig,
transport : Transport,
request : SystemOneRequest,
sleep_ms : (Int) -> Unit,
now_secs : () -> Int,
) -> Result[SystemOneResponse, SdkError] {
let http_request = build_http_request(config, request)
let mut attempts_done = 0
let mut last : Result[SystemOneResponse, SdkError] = match
(transport.deliver)(http_request) {
Err(NetworkFailure(message)) => Err(Network(message))
Ok(response) => classify_response(response, now_secs())
}
while should_retry(attempts_done, config.retry.max_retries) {
match last {
Ok(_) => break
Err(err) =>
if is_retryable(err) {
sleep_ms(
delay_ms(
config.retry.base_delay_ms,
config.retry.max_delay_ms,
attempts_done,
retry_after_secs_of(err),
),
)
attempts_done = attempts_done + 1
last = match (transport.deliver)(http_request) {
Err(NetworkFailure(message)) => Err(Network(message))
Ok(response) => classify_response(response, now_secs())
}
} else {
break
}
}
}
last
}
///|
fn evaluate_attempt_async(
config : ClientConfig,
http_request : HttpRequest,
transport : AsyncTransport,
sleep_async : (Int, () -> Unit) -> Unit,
now_secs : () -> Int,
attempts_done : Int,
on_done : (Result[SystemOneResponse, SdkError]) -> Unit,
) -> Unit {
(transport.deliver)(http_request, fn(response) {
let outcome : Result[SystemOneResponse, SdkError] = match response {
Ok(resp) => classify_response(resp, now_secs())
Err(NetworkFailure(message)) => Err(Network(message))
}
match outcome {
Ok(_) => on_done(outcome)
Err(err) =>
if should_retry(attempts_done, config.retry.max_retries) &&
is_retryable(err) {
sleep_async(
delay_ms(
config.retry.base_delay_ms,
config.retry.max_delay_ms,
attempts_done,
retry_after_secs_of(err),
),
fn() {
evaluate_attempt_async(
config,
http_request,
transport,
sleep_async,
now_secs,
attempts_done + 1,
on_done,
)
},
)
} else {
on_done(outcome)
}
}
})
}
///|
/// Evaluate with retries over an asynchronous transport. Completion is
/// delivered to `on_done`; `sleep_async(ms, wake)` schedules the retry
/// delay (tests pass an immediate scheduler); `now_secs` resolves
/// HTTP-date Retry-After headers. The retry policy is the same verified
/// core as the blocking path.
pub fn evaluate_with_retries_async(
config : ClientConfig,
transport : AsyncTransport,
request : SystemOneRequest,
sleep_async : (Int, () -> Unit) -> Unit,
now_secs : () -> Int,
on_done : (Result[SystemOneResponse, SdkError]) -> Unit,
) -> Unit {
evaluate_attempt_async(
config,
build_http_request(config, request),
transport,
sleep_async,
now_secs,
0,
on_done,
)
}