Skip to content

Commit d48919f

Browse files
committed
HTTP followup: move Error and Timeout out of spacetimedb_lib
1 parent 7df8719 commit d48919f

5 files changed

Lines changed: 228 additions & 250 deletions

File tree

crates/bindings/src/http.rs

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
//! while [`send`](HttpClient::send) allows more complex requests with headers, bodies and other methods.
77
88
use bytes::Bytes;
9-
pub use http::{Request, Response};
10-
pub use spacetimedb_lib::http::{Error, Timeout};
119

1210
use crate::{
1311
rt::{read_bytes_source_as, read_bytes_source_into},
1412
IterBuf,
1513
};
16-
use spacetimedb_lib::{bsatn, http as st_http};
14+
use spacetimedb_lib::{bsatn, http as st_http, TimeDuration};
15+
16+
pub type Request<T = Body> = http::Request<T>;
17+
18+
pub type Response<T = Body> = http::Response<T>;
1719

1820
/// Allows performing HTTP requests via [`HttpClient::send`] and [`HttpClient::get`].
1921
///
@@ -43,8 +45,7 @@ impl HttpClient {
4345
/// and a timeout of 100 milliseconds, then treat the response as a string and log it:
4446
///
4547
/// ```norun
46-
/// # use spacetimedb::{procedure, ProcedureContext};
47-
/// # use spacetimedb::http::{Request, Timeout};
48+
/// # use spacetimedb::{procedure, ProcedureContext, http::Timeout};
4849
/// # use std::time::Duration;
4950
/// # #[procedure]
5051
/// # fn post_somewhere(ctx: &mut ProcedureContext) {
@@ -73,25 +74,24 @@ impl HttpClient {
7374
/// # }
7475
///
7576
/// ```
76-
pub fn send<B: Into<Body>>(&self, request: Request<B>) -> Result<Response<Body>, Error> {
77+
pub fn send<B: Into<Body>>(&self, request: http::Request<B>) -> Result<Response, Error> {
7778
let (request, body) = request.map(Into::into).into_parts();
78-
let request = st_http::Request::from(request);
79+
let request = convert_request(request);
7980
let request = bsatn::to_vec(&request).expect("Failed to BSATN-serialize `spacetimedb_lib::http::Request`");
8081

8182
match spacetimedb_bindings_sys::procedure::http_request(&request, &body.into_bytes()) {
8283
Ok((response_source, body_source)) => {
8384
let response = read_bytes_source_as::<st_http::Response>(response_source);
84-
let response =
85-
http::response::Parts::try_from(response).expect("Invalid http response returned from host");
85+
let response = convert_response(response).expect("Invalid http response returned from host");
8686
let mut buf = IterBuf::take();
8787
read_bytes_source_into(body_source, &mut buf);
8888
let body = Body::from_bytes(buf.clone());
8989

9090
Ok(http::Response::from_parts(response, body))
9191
}
9292
Err(err_source) => {
93-
let error = read_bytes_source_as::<st_http::Error>(err_source);
94-
Err(error)
93+
let message = read_bytes_source_as::<String>(err_source);
94+
Err(Error { message })
9595
}
9696
}
9797
}
@@ -121,17 +121,49 @@ impl HttpClient {
121121
/// }
122122
/// # }
123123
/// ```
124-
pub fn get(&self, uri: impl TryInto<http::Uri, Error: Into<http::Error>>) -> Result<Response<Body>, Error> {
124+
pub fn get(&self, uri: impl TryInto<http::Uri, Error: Into<http::Error>>) -> Result<Response, Error> {
125125
self.send(
126126
http::Request::builder()
127-
.method("GET")
127+
.method(http::Method::GET)
128128
.uri(uri)
129-
.body(Body::empty())
130-
.map_err(|err| Error::from_display(&err))?,
129+
.body(Body::empty())?,
131130
)
132131
}
133132
}
134133

134+
fn convert_request(parts: http::request::Parts) -> st_http::Request {
135+
let http::request::Parts {
136+
method,
137+
uri,
138+
version,
139+
headers,
140+
mut extensions,
141+
..
142+
} = parts;
143+
144+
let timeout = extensions.remove::<Timeout>();
145+
if !extensions.is_empty() {
146+
log::warn!("Converting HTTP `Request` with unrecognized extensions");
147+
}
148+
st_http::Request {
149+
method: method.into(),
150+
headers: headers.into(),
151+
timeout: timeout.map(Into::into),
152+
uri: uri.to_string(),
153+
version: version.into(),
154+
}
155+
}
156+
157+
fn convert_response(response: st_http::Response) -> http::Result<http::response::Parts> {
158+
let st_http::Response { headers, version, code } = response;
159+
160+
let (mut response, ()) = http::Response::new(()).into_parts();
161+
response.version = version.into();
162+
response.status = http::StatusCode::from_u16(code)?;
163+
response.headers = headers.try_into()?;
164+
Ok(response)
165+
}
166+
135167
/// Represents the body of an HTTP request or response.
136168
pub struct Body {
137169
inner: BodyInner,
@@ -208,3 +240,58 @@ impl_body_from_bytes!(_unit: () => Bytes::new());
208240
enum BodyInner {
209241
Bytes(Bytes),
210242
}
243+
244+
/// An HTTP extension to specify a timeout for requests made by a procedure running in a SpacetimeDB database.
245+
///
246+
/// Pass an instance of this type to [`http::request::Builder::extension`] to set a timeout on a request.
247+
///
248+
/// This timeout applies to the entire request,
249+
/// from when the headers are first sent to when the response body is fully downloaded.
250+
/// This is sometimes called a total timeout, the sum of the connect timeout and the read timeout.
251+
#[derive(Clone, Copy, PartialEq, Eq)]
252+
pub struct Timeout(pub TimeDuration);
253+
254+
impl From<TimeDuration> for Timeout {
255+
fn from(timeout: TimeDuration) -> Timeout {
256+
Timeout(timeout)
257+
}
258+
}
259+
260+
impl From<Timeout> for TimeDuration {
261+
fn from(Timeout(timeout): Timeout) -> TimeDuration {
262+
timeout
263+
}
264+
}
265+
266+
/// An error that may arise from an HTTP call.
267+
#[derive(Clone, Debug)]
268+
pub struct Error {
269+
/// A string message describing the error.
270+
///
271+
/// It would be nice if we could store a more interesting object here,
272+
/// ideally a type-erased `dyn Trait` cause,
273+
/// rather than just a string, similar to how `anyhow` does.
274+
/// This is not possible because we need to serialize `Error` for transport to WASM,
275+
/// meaning it must have a concrete static type.
276+
/// `reqwest::Error`, which is the source for these,
277+
/// is type-erased enough that the best we can do (at least, the best we can do easily)
278+
/// is to eagerly string-ify the error.
279+
message: String,
280+
}
281+
282+
impl std::fmt::Display for Error {
283+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
284+
let Error { message } = self;
285+
f.write_str(message)
286+
}
287+
}
288+
289+
impl std::error::Error for Error {}
290+
291+
impl From<http::Error> for Error {
292+
fn from(err: http::Error) -> Self {
293+
Error {
294+
message: err.to_string(),
295+
}
296+
}
297+
}

crates/core/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ pub enum NodesError {
282282
#[error("Failed to scheduled timer: {0}")]
283283
ScheduleError(#[source] ScheduleError),
284284
#[error("HTTP request failed: {0}")]
285-
HttpError(#[from] spacetimedb_lib::http::Error),
285+
HttpError(String),
286286
}
287287

288288
impl From<DBError> for NodesError {

crates/core/src/host/instance_env.rs

Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -618,68 +618,45 @@ impl InstanceEnv {
618618

619619
// TODO(procedure-metrics): record size in bytes of request.
620620

621+
fn http_error<E: ToString>(err: E) -> NodesError {
622+
NodesError::HttpError(err.to_string())
623+
}
624+
621625
// Then convert the request into an `http::Request`, a semi-standard "lingua franca" type in the Rust ecosystem,
622626
// and map its body into a type `reqwest` will like.
623-
fn convert_request(request: st_http::Request, body: bytes::Bytes) -> Result<reqwest::Request, st_http::Error> {
624-
let mut request: http::request::Parts =
625-
request.try_into().map_err(|err| st_http::Error::from_display(&err))?;
627+
let (request, timeout) = convert_http_request(request).map_err(http_error)?;
626628

627-
// Pull our timeout extension, if any, out of the `http::Request` extensions.
628-
// reqwest has its own timeout extension, which is where we'll provide this.
629-
let timeout = request.extensions.remove::<st_http::Timeout>();
629+
let request = http::Request::from_parts(request, body);
630630

631-
let request = http::Request::from_parts(request, body.to_vec());
631+
let mut reqwest: reqwest::Request = request.try_into().map_err(http_error)?;
632632

633-
let mut reqwest: reqwest::Request = request.try_into().map_err(|err| st_http::Error::from_display(&err))?;
633+
// If the user requested a timeout using our extension, slot it in to reqwest's timeout.
634+
// Clamp to the range `0..HTTP_DEFAULT_TIMEOUT`.
635+
let timeout = timeout.unwrap_or(HTTP_DEFAULT_TIMEOUT).min(HTTP_DEFAULT_TIMEOUT);
634636

635-
// If the user requested a timeout using our extension, slot it in to reqwest's timeout.
636-
// Clamp to the range `0..HTTP_DEFAULT_TIMEOUT`.
637-
let timeout = timeout
638-
.map(|timeout| timeout.timeout.to_duration().unwrap_or(Duration::ZERO))
639-
.unwrap_or(HTTP_DEFAULT_TIMEOUT)
640-
.min(HTTP_DEFAULT_TIMEOUT);
637+
// reqwest's timeout covers from the start of the request to the end of reading the body,
638+
// so there's no need to do our own timeout operation.
639+
*reqwest.timeout_mut() = Some(timeout);
641640

642-
// reqwest's timeout covers from the start of the request to the end of reading the body,
643-
// so there's no need to do our own timeout operation.
644-
*reqwest.timeout_mut() = Some(timeout);
645-
646-
Ok(reqwest)
647-
}
648-
649-
// If for whatever reason reqwest doesn't like our `http::Request`,
650-
// surface that error to the guest so customers can debug and provide a more appropriate request.
651-
let reqwest = convert_request(request, body)?;
641+
let reqwest = reqwest;
652642

653643
// TODO(procedure-metrics): record size in bytes of response, time spent awaiting response.
654644

655645
// Actually execute the HTTP request!
656-
// We'll wrap this future in a `tokio::time::timeout` before `await`ing it.
657-
let get_response_and_download_body = async {
658-
// TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call.
659-
let response = reqwest::Client::new()
660-
.execute(reqwest)
661-
.await
662-
.map_err(|err| st_http::Error::from_display(&err))?;
663-
664-
// Download the response body, which in all likelihood will be a stream,
665-
// as reqwest seems to prefer that.
666-
// Note that this will be wrapped in the same `tokio::time::timeout` as the above `execute` call.
667-
let (parts, body) = http::Response::from(response).into_parts();
668-
let body = http_body_util::BodyExt::collect(body)
669-
.await
670-
.map_err(|err| st_http::Error::from_display(&err))?;
671-
672-
// Map the collected body into our `spacetimedb_lib::http::Body` type,
673-
// then wrap it back in an `http::Response`.
674-
Ok::<_, st_http::Error>((parts, body.to_bytes()))
675-
};
646+
// TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call.
647+
let response = reqwest::Client::new().execute(reqwest).await.map_err(http_error)?;
676648

677-
// If the request failed, surface that error to the guest so customer logic can handle it.
678-
let (response, body) = get_response_and_download_body.await?;
649+
// Download the response body, which in all likelihood will be a stream,
650+
// as reqwest seems to prefer that.
651+
let (response, body) = http::Response::from(response).into_parts();
652+
let body = http_body_util::BodyExt::collect(body)
653+
.await
654+
.map_err(http_error)?
655+
.to_bytes();
679656

680657
// Transform the `http::Response` into our `spacetimedb_lib::http::Response` type,
681658
// which has a stable BSATN encoding to pass across the WASM boundary.
682-
let response = st_http::Response::from(response);
659+
let response = convert_http_response(response);
683660

684661
Ok((response, body))
685662
}
@@ -692,6 +669,46 @@ impl InstanceEnv {
692669
/// Value chosen arbitrarily by pgoldman 2025-11-18, based on little more than a vague guess.
693670
const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_millis(500);
694671

672+
fn convert_http_request(request: st_http::Request) -> http::Result<(http::request::Parts, Option<Duration>)> {
673+
let st_http::Request {
674+
method,
675+
headers,
676+
timeout,
677+
uri,
678+
version,
679+
} = request;
680+
681+
let (mut request, ()) = http::Request::new(()).into_parts();
682+
request.method = method.into();
683+
request.uri = uri.try_into()?;
684+
request.version = version.into();
685+
request.headers = headers.try_into()?;
686+
687+
let timeout = timeout.map(|d| d.to_duration_saturating());
688+
689+
Ok((request, timeout))
690+
}
691+
692+
fn convert_http_response(response: http::response::Parts) -> st_http::Response {
693+
let http::response::Parts {
694+
extensions,
695+
headers,
696+
status,
697+
version,
698+
..
699+
} = response;
700+
701+
// there's a good chance that reqwest inserted some extensions into this request,
702+
// but we can't control that and don't care much about it.
703+
let _ = extensions;
704+
705+
st_http::Response {
706+
headers: headers.into(),
707+
version: version.into(),
708+
code: status.as_u16(),
709+
}
710+
}
711+
695712
impl TxSlot {
696713
/// Sets the slot to `tx`, ensuring that there was no tx before.
697714
pub fn set_raw(&mut self, tx: MutTxId) {

0 commit comments

Comments
 (0)