Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions crates/shepherd-cow-host/src/ext_cow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use std::time::Instant;

use alloy_chains::Chain;
use nexum_runtime::bindings::nexum::host::types::Fault;
use nexum_runtime::bindings::nexum::host::types::{Fault, RateLimit};
use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTypes};
use nexum_runtime::host::extension::Extension;
use nexum_runtime::host::state::{ExtState, HostState};
Expand Down Expand Up @@ -109,8 +109,11 @@ where
///
/// Local-shape failures (unknown chain, bad method/path, decode) become
/// a shared [`Fault`]; a transport-layer HTTP failure becomes an
/// [`Http`](bindings::shepherd::cow::cow_api::CowApiError::Http) case; an
/// orderbook rejection envelope is parsed once here into a
/// [`Http`](bindings::shepherd::cow::cow_api::CowApiError::Http) case,
/// except 429 and a `reqwest` timeout, which carry a more precise
/// [`Fault::RateLimited`] / [`Fault::Timeout`] instead - mirroring the
/// chain-host's `transport_fault` classification; an orderbook
/// rejection envelope is parsed once here into a
/// [`Rejected`](bindings::shepherd::cow::cow_api::CowApiError::Rejected)
/// case so the guest never re-decodes the failure body.
fn cow_error_to_wit(err: CowApiError) -> WitCowApiError {
Expand All @@ -122,10 +125,16 @@ fn cow_error_to_wit(err: CowApiError) -> WitCowApiError {
WitCowApiError::Fault(Fault::InvalidInput(format!("unsupported HTTP method: {m}")))
}
CowApiError::BadPath(msg) => WitCowApiError::Fault(Fault::InvalidInput(msg)),
CowApiError::HttpError { status: 429, .. } => {
WitCowApiError::Fault(Fault::RateLimited(RateLimit {
retry_after_ms: None,
}))
}
CowApiError::HttpError { status, body } => WitCowApiError::Http(HttpFailure {
status,
body: Some(body),
}),
CowApiError::Network(e) if e.is_timeout() => WitCowApiError::Fault(Fault::Timeout),
CowApiError::Network(e) => WitCowApiError::Fault(Fault::Unavailable(e.to_string())),
CowApiError::Decode(e) => WitCowApiError::Fault(Fault::InvalidInput(format!(
"invalid OrderCreation JSON: {e}"
Expand Down Expand Up @@ -294,4 +303,56 @@ mod tests {
WitCowApiError::Fault(Fault::Unsupported(_)),
));
}

#[test]
fn backend_http_429_projects_to_rate_limited_fault() {
// 429 is backpressure, not a rejection body the guest needs to
// inspect - it must reach the shared rate-limited vocabulary
// instead of a raw http-failure, unlike every other status.
let err = CowApiError::HttpError {
status: 429,
body: "slow down".to_owned(),
};

assert!(matches!(
cow_error_to_wit(err),
WitCowApiError::Fault(Fault::RateLimited(RateLimit {
retry_after_ms: None
})),
));
}

#[tokio::test]
async fn backend_network_timeout_projects_to_timeout_fault() {
// A `reqwest` timeout (any phase) must become `Fault::Timeout`,
// not the blanket `Fault::Unavailable` every other transport
// failure gets. Accept the connection but never write a
// response, so the client's deadline fires on a genuine
// read-timeout - deterministic, unlike relying on how a given
// CI runner's network stack handles an unroutable address
// (which can fail instantly with "no route to host" instead
// of timing out).
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral local port");
let addr = listener.local_addr().expect("read the bound local addr");
tokio::spawn(async move {
let (_socket, _) = listener.accept().await.expect("accept the test connection");
std::future::pending::<()>().await;
});

let client = reqwest::Client::new();
let send_err = client
.get(format!("http://{addr}/"))
.timeout(std::time::Duration::from_millis(50))
.send()
.await
.expect_err("request against a stalled server must fail");
assert!(send_err.is_timeout(), "expected a timeout error");

assert!(matches!(
cow_error_to_wit(CowApiError::Network(send_err)),
WitCowApiError::Fault(Fault::Timeout),
));
}
}
Loading