///|
/// A `ChromaClient` transport backed by `moonbitlang/async/http`.
///
/// This is the piece that turns the client's request objects into real
/// sockets. It implements `AsyncTransport`, so it plugs into the `*_async`
/// methods, and it is deliberately thin: it maps the client's
/// method/header vocabulary onto `@ah`, sends one request, and reads the
/// whole body back.
///
/// It lives in the library rather than in a test or example package because
/// a Chroma client that cannot reach a server is not useful: `preferred_target
/// = "wasm"` describes where the *client* runs, and this transport is how it
/// talks to Chroma from native and wasm alike.
///
/// One connection per request is enough for a demo and for a test suite. A
/// long-lived application that cares about latency should keep an `@ah.Client`
/// open and reuse it, because `@ah.Client` keeps the connection alive across
/// requests; see the "Native transport" section of the README.
///
/// The transport holds no state: the client already puts the full URL and the
/// deadline in every request, so there is nothing to join and nothing to
/// configure here — see `AsyncHttpTransport::from_config` if you would rather
/// build one from a `Config`.
///
/// # Timeouts and retry waits
///
/// A Chroma server does not implement `x-chroma-timeout-ms` or
/// `x-chroma-retry-delay-ms`: they are a contract between this client and
/// *a transport*, and the one that ships here honours them itself, because it
/// is the only place that owns a socket and can cancel or sleep.
///
/// - `x-chroma-timeout-ms: n` wraps the whole exchange (connect, send, read)
/// in `@async.with_timeout(n)`. Exceeding it raises
/// `ChromaError::Transport`, which is what a caller can catch.
/// - `x-chroma-retry-delay-ms: n` makes the transport sleep `n` milliseconds
/// *before* sending, which is what `RetryTransport` asks of it. A caller
/// that does not want to wait should not enable retries: the sleep happens
/// here rather than inside `RetryTransport` precisely so that a wrapped
/// transport without a runtime (a scripted double, say) never blocks.
///
/// Both headers are consumed locally and stripped before the request goes on
/// the wire, so a Chroma server never sees client-internal state as a header
/// it might reject.
///
/// # Redirects
///
/// `@ah.request` does not follow `3xx`, and `ChromaClient` maps every non-2xx
/// status to `Api` / `Status` — so a redirect (an http→https proxy in front of
/// Chroma, say) surfaces as an error rather than as a silently re-sent
/// request. Point `Config::endpoint` at the final URL instead.
pub struct AsyncHttpTransport {}
///|
/// Builds a transport.
pub fn AsyncHttpTransport::new() -> AsyncHttpTransport {
AsyncHttpTransport::{ }
}
///|
/// Maps the client's verb onto the HTTP stack's.
fn to_ah_request_method(verb : Method) -> @ah.RequestMethod {
match verb {
Get => @ah.RequestMethod::Get
Post => @ah.RequestMethod::Post
Put => @ah.RequestMethod::Put
Delete => @ah.RequestMethod::Delete
Patch => @ah.RequestMethod::Patch
}
}
///|
pub impl AsyncTransport for AsyncHttpTransport with fn send_async(self, request) {
ignore(self)
let request = without_internal_headers(request)
// `@ah.Headers` is a `Map[CaseInsensitiveString, String]`, and
// `CaseInsensitiveString` is implicitly convertible from `String`, so the
// header names the client produced can be used as map keys unchanged.
let headers : @ah.Headers = Map([])
for entry in request.headers {
let (key, value) = entry
headers[key] = value
}
// A GET carries no request body in the client, and `@aio.Data` has no
// "absent" case, so an empty byte string is the neutral value here.
let payload : &@aio.Data = match request.body {
Some(body) => body
None => ""
}
// The retry delay is a wait the caller asked for, not a request the server
// should see: sleeping here keeps `RetryTransport` itself free of I/O. The
// wait lives in this one helper so it can be tested without a socket.
match request_retry_delay_ms(request) {
Some(delay) => sleep_async(delay)
None => ()
}
let send = async fn() -> (@ah.Response, &@aio.Data) {
@ah.request(
request.url,
to_ah_request_method(request.verb),
headers,
payload,
)
}
// The whole exchange is inside the deadline: connecting is where most of a
// cancelled request's time goes, so timing only the read would miss it.
let (response, response_body) = match request_timeout_ms(request) {
Some(timeout_ms) =>
@async.with_timeout(
timeout_ms,
send,
error=ChromaError::Transport(
"request to " +
request.url +
" timed out after " +
timeout_ms.to_string() +
"ms",
),
)
None => send()
}
let out_headers : Map[String, String] = Map([])
for entry in response.headers {
let (key, value) = entry
// `Map[String, String]` is what the client's `Response` expects; the
// original casing is not preserved by the case-insensitive key, and
// nothing in the client reads a header by name, so lowercasing loses
// nothing.
out_headers[key.to_string().to_lower()] = value
}
{ status: response.code, headers: out_headers, body: response_body.text(), }
}
///|
pub extend AsyncHttpTransport with AsyncTransport::{send_async}
///|
/// Builds a transport from a `Config`, applying `config.timeout_ms` as a
/// deadline on the socket.
///
/// This is the wiring `Config::timeout_ms` was meant to have: the header
/// `TimeoutTransport` stamps is honoured by `AsyncHttpTransport` above, and a
/// client built with this transport also stamps the header itself (see
/// `ChromaClient::send_async`), so both routes end up at the same value.
///
/// A non-positive `timeout_ms` means "no deadline".
pub fn AsyncHttpTransport::from_config(
config : Config,
) -> TimeoutAsyncTransport[AsyncHttpTransport] {
TimeoutAsyncTransport::new(AsyncHttpTransport::new(), config.timeout_ms)
}
///|
/// The async counterpart of `TimeoutTransport`: stamps a deadline that an
/// `AsyncTransport` honours.
///
/// It exists because `Config::timeout_ms` has to reach the socket through the
/// async half too. `TimeoutTransport` wraps the synchronous `Transport` trait,
/// which none of the `*_async` methods use, so a client built as
/// `ChromaClient::new(TimeoutTransport::new(AsyncHttpTransport::new(), 5000))`
/// would not even compile: the async methods need an `AsyncTransport`.
///
/// `AsyncHttpTransport` turns the header into a real `@async.with_timeout`,
/// so wrapping it is what makes the deadline effective; wrapping a double
/// that ignores the header is harmless.
pub(all) struct TimeoutAsyncTransport[T] {
inner : T
timeout_ms : Int32
}
///|
/// Wraps `inner`, asking for a `timeout_ms` deadline on every request. A
/// non-positive value passes requests through untouched, so the wrapper can be
/// driven straight from `Config::timeout_ms`.
pub fn[T] TimeoutAsyncTransport::new(
inner : T,
timeout_ms : Int32,
) -> TimeoutAsyncTransport[T] {
{ inner, timeout_ms, }
}
///|
/// Builds a deadline wrapper from a client configuration.
pub fn[T] TimeoutAsyncTransport::from_config(
inner : T,
config : Config,
) -> TimeoutAsyncTransport[T] {
{ inner, timeout_ms: config.timeout_ms, }
}
///|
/// Returns the deadline this transport asks for.
pub fn[T] TimeoutAsyncTransport::timeout_ms(
self : TimeoutAsyncTransport[T],
) -> Int32 {
self.timeout_ms
}
///|
pub impl[T : AsyncTransport] AsyncTransport for TimeoutAsyncTransport[T] with fn send_async(
self,
request,
) {
if self.timeout_ms <= 0 {
return self.inner.send_async(request)
}
let headers = request.headers.copy()
headers[TIMEOUT_HEADER] = self.timeout_ms.to_string()
self.inner.send_async({ ..request, headers, })
}
///|
pub extend TimeoutAsyncTransport with AsyncTransport::{send_async}
///|
/// Waits `delay_ms` milliseconds before a retry.
///
/// `RetryTransport` deliberately never sleeps; this is the other half of that
/// contract, in the only place a real runtime exists. A non-positive delay is
/// a no-op, which is what "no backoff configured" means.
async fn sleep_async(delay_ms : Int32) -> Unit {
if delay_ms > 0 {
@async.sleep(delay_ms)
}
}