Skip to content

Commit de142d4

Browse files
authored
HTTP followup: remove http dep from spacetimedb_lib (#3719)
# Description of Changes Follow up to #3684. Moves `Error` and `Timeout` out of lib, so that we don't have to implement `SpacetimeType` for them, and then removes the http dependency altogether, so that `lib` can be leaner. I also got rid of the separate `HttpValue` type, since it only really exists to mirror the `http` crate and typescript won't make use of it. # Expected complexity level and risk 1 # Testing n/a - just code movement.
1 parent dc881f2 commit de142d4

File tree

8 files changed

+293
-360
lines changed

8 files changed

+293
-360
lines changed

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bindings/src/http.rs

Lines changed: 132 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,79 @@ 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: match method {
150+
http::Method::GET => st_http::Method::Get,
151+
http::Method::HEAD => st_http::Method::Head,
152+
http::Method::POST => st_http::Method::Post,
153+
http::Method::PUT => st_http::Method::Put,
154+
http::Method::DELETE => st_http::Method::Delete,
155+
http::Method::CONNECT => st_http::Method::Connect,
156+
http::Method::OPTIONS => st_http::Method::Options,
157+
http::Method::TRACE => st_http::Method::Trace,
158+
http::Method::PATCH => st_http::Method::Patch,
159+
_ => st_http::Method::Extension(method.to_string()),
160+
},
161+
headers: headers
162+
.into_iter()
163+
.map(|(k, v)| (k.map(|k| k.as_str().into()), v.as_bytes().into()))
164+
.collect(),
165+
timeout: timeout.map(Into::into),
166+
uri: uri.to_string(),
167+
version: match version {
168+
http::Version::HTTP_09 => st_http::Version::Http09,
169+
http::Version::HTTP_10 => st_http::Version::Http10,
170+
http::Version::HTTP_11 => st_http::Version::Http11,
171+
http::Version::HTTP_2 => st_http::Version::Http2,
172+
http::Version::HTTP_3 => st_http::Version::Http3,
173+
_ => unreachable!("Unknown HTTP version: {version:?}"),
174+
},
175+
}
176+
}
177+
178+
fn convert_response(response: st_http::Response) -> http::Result<http::response::Parts> {
179+
let st_http::Response { headers, version, code } = response;
180+
181+
let (mut response, ()) = http::Response::new(()).into_parts();
182+
response.version = match version {
183+
st_http::Version::Http09 => http::Version::HTTP_09,
184+
st_http::Version::Http10 => http::Version::HTTP_10,
185+
st_http::Version::Http11 => http::Version::HTTP_11,
186+
st_http::Version::Http2 => http::Version::HTTP_2,
187+
st_http::Version::Http3 => http::Version::HTTP_3,
188+
};
189+
response.status = http::StatusCode::from_u16(code)?;
190+
response.headers = headers
191+
.into_iter()
192+
.map(|(k, v)| Ok((k.into_string().try_into()?, v.into_vec().try_into()?)))
193+
.collect::<http::Result<_>>()?;
194+
Ok(response)
195+
}
196+
135197
/// Represents the body of an HTTP request or response.
136198
pub struct Body {
137199
inner: BodyInner,
@@ -208,3 +270,58 @@ impl_body_from_bytes!(_unit: () => Bytes::new());
208270
enum BodyInner {
209271
Bytes(Bytes),
210272
}
273+
274+
/// An HTTP extension to specify a timeout for requests made by a procedure running in a SpacetimeDB database.
275+
///
276+
/// Pass an instance of this type to [`http::request::Builder::extension`] to set a timeout on a request.
277+
///
278+
/// This timeout applies to the entire request,
279+
/// from when the headers are first sent to when the response body is fully downloaded.
280+
/// This is sometimes called a total timeout, the sum of the connect timeout and the read timeout.
281+
#[derive(Clone, Copy, PartialEq, Eq)]
282+
pub struct Timeout(pub TimeDuration);
283+
284+
impl From<TimeDuration> for Timeout {
285+
fn from(timeout: TimeDuration) -> Timeout {
286+
Timeout(timeout)
287+
}
288+
}
289+
290+
impl From<Timeout> for TimeDuration {
291+
fn from(Timeout(timeout): Timeout) -> TimeDuration {
292+
timeout
293+
}
294+
}
295+
296+
/// An error that may arise from an HTTP call.
297+
#[derive(Clone, Debug)]
298+
pub struct Error {
299+
/// A string message describing the error.
300+
///
301+
/// It would be nice if we could store a more interesting object here,
302+
/// ideally a type-erased `dyn Trait` cause,
303+
/// rather than just a string, similar to how `anyhow` does.
304+
/// This is not possible because we need to serialize `Error` for transport to WASM,
305+
/// meaning it must have a concrete static type.
306+
/// `reqwest::Error`, which is the source for these,
307+
/// is type-erased enough that the best we can do (at least, the best we can do easily)
308+
/// is to eagerly string-ify the error.
309+
message: String,
310+
}
311+
312+
impl std::fmt::Display for Error {
313+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
314+
let Error { message } = self;
315+
f.write_str(message)
316+
}
317+
}
318+
319+
impl std::error::Error for Error {}
320+
321+
impl From<http::Error> for Error {
322+
fn from(err: http::Error) -> Self {
323+
Error {
324+
message: err.to_string(),
325+
}
326+
}
327+
}

crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ spacetimedb
8383
│ ├── derive_more (*)
8484
│ ├── enum_as_inner (*)
8585
│ ├── hex
86-
│ ├── http (*)
8786
│ ├── itertools (*)
8887
│ ├── log
8988
│ ├── spacetimedb_bindings_macro (*)

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: 94 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))?;
626-
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>();
627+
let (request, timeout) = convert_http_request(request).map_err(http_error)?;
630628

631-
let request = http::Request::from_parts(request, body.to_vec());
629+
let request = http::Request::from_parts(request, body);
632630

633-
let mut reqwest: reqwest::Request = request.try_into().map_err(|err| st_http::Error::from_display(&err))?;
631+
let mut reqwest: reqwest::Request = request.try_into().map_err(http_error)?;
634632

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);
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);
641636

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-
}
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);
648640

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,76 @@ 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 = match method {
683+
st_http::Method::Get => http::Method::GET,
684+
st_http::Method::Head => http::Method::HEAD,
685+
st_http::Method::Post => http::Method::POST,
686+
st_http::Method::Put => http::Method::PUT,
687+
st_http::Method::Delete => http::Method::DELETE,
688+
st_http::Method::Connect => http::Method::CONNECT,
689+
st_http::Method::Options => http::Method::OPTIONS,
690+
st_http::Method::Trace => http::Method::TRACE,
691+
st_http::Method::Patch => http::Method::PATCH,
692+
st_http::Method::Extension(method) => http::Method::from_bytes(method.as_bytes()).expect("Invalid HTTP method"),
693+
};
694+
request.uri = uri.try_into()?;
695+
request.version = match version {
696+
st_http::Version::Http09 => http::Version::HTTP_09,
697+
st_http::Version::Http10 => http::Version::HTTP_10,
698+
st_http::Version::Http11 => http::Version::HTTP_11,
699+
st_http::Version::Http2 => http::Version::HTTP_2,
700+
st_http::Version::Http3 => http::Version::HTTP_3,
701+
};
702+
request.headers = headers
703+
.into_iter()
704+
.map(|(k, v)| Ok((k.into_string().try_into()?, v.into_vec().try_into()?)))
705+
.collect::<http::Result<_>>()?;
706+
707+
let timeout = timeout.map(|d| d.to_duration_saturating());
708+
709+
Ok((request, timeout))
710+
}
711+
712+
fn convert_http_response(response: http::response::Parts) -> st_http::Response {
713+
let http::response::Parts {
714+
extensions,
715+
headers,
716+
status,
717+
version,
718+
..
719+
} = response;
720+
721+
// there's a good chance that reqwest inserted some extensions into this request,
722+
// but we can't control that and don't care much about it.
723+
let _ = extensions;
724+
725+
st_http::Response {
726+
headers: headers
727+
.into_iter()
728+
.map(|(k, v)| (k.map(|k| k.as_str().into()), v.as_bytes().into()))
729+
.collect(),
730+
version: match version {
731+
http::Version::HTTP_09 => st_http::Version::Http09,
732+
http::Version::HTTP_10 => st_http::Version::Http10,
733+
http::Version::HTTP_11 => st_http::Version::Http11,
734+
http::Version::HTTP_2 => st_http::Version::Http2,
735+
http::Version::HTTP_3 => st_http::Version::Http3,
736+
_ => unreachable!("Unknown HTTP version: {version:?}"),
737+
},
738+
code: status.as_u16(),
739+
}
740+
}
741+
695742
impl TxSlot {
696743
/// Sets the slot to `tx`, ensuring that there was no tx before.
697744
pub fn set_raw(&mut self, tx: MutTxId) {

0 commit comments

Comments
 (0)