Skip to content

Commit e047da6

Browse files
committed
Improve HttpClient in Rust
- Add HttpStatus type with convenience methods - Add tests
1 parent 506800b commit e047da6

File tree

3 files changed

+156
-42
lines changed

3 files changed

+156
-42
lines changed

nautilus_core/network/benches/test_client.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async fn main() {
3636

3737
let resp = futures::future::join_all(reqs.drain(0..)).await;
3838
assert!(resp.iter().all(|res| if let Ok(resp) = res {
39-
resp.status == 200
39+
resp.status.is_success()
4040
} else {
4141
false
4242
}));

nautilus_core/network/src/http.rs

+147-33
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,85 @@
1818
use std::{collections::HashMap, hash::Hash, str::FromStr, sync::Arc, time::Duration};
1919

2020
use bytes::Bytes;
21-
use http::HeaderValue;
21+
use http::{status::InvalidStatusCode, HeaderValue, StatusCode};
2222
use reqwest::{
2323
header::{HeaderMap, HeaderName},
2424
Method, Response, Url,
2525
};
2626

2727
use crate::ratelimiter::{clock::MonotonicClock, quota::Quota, RateLimiter};
2828

29+
/// Represents a HTTP status code.
30+
///
31+
/// Wraps [`http::StatusCode`] to expose a Python-compatible type and reuse
32+
/// its validation and convenience methods.
33+
#[derive(Clone, Debug)]
34+
#[cfg_attr(
35+
feature = "python",
36+
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network")
37+
)]
38+
pub struct HttpStatus {
39+
inner: StatusCode,
40+
}
41+
42+
impl HttpStatus {
43+
/// Create a new [`HttpStatus`] instance from a given [`StatusCode`].
44+
pub fn new(code: StatusCode) -> Self {
45+
Self { inner: code }
46+
}
47+
48+
/// Attempts to construct a [`HttpStatus`] from a `u16`.
49+
///
50+
/// Returns an error if the code is not in the valid `100..999` range.
51+
pub fn from(code: u16) -> Result<Self, InvalidStatusCode> {
52+
Ok(Self {
53+
inner: StatusCode::from_u16(code)?,
54+
})
55+
}
56+
57+
/// Returns the status code as a `u16` (e.g., `200` for OK).
58+
#[inline]
59+
pub const fn as_u16(&self) -> u16 {
60+
self.inner.as_u16()
61+
}
62+
63+
/// Returns the three-digit ASCII representation of this status (e.g., `"200"`).
64+
#[inline]
65+
pub fn as_str(&self) -> &str {
66+
self.inner.as_str()
67+
}
68+
69+
/// Checks if this status is in the 1xx (informational) range.
70+
#[inline]
71+
pub fn is_informational(&self) -> bool {
72+
self.inner.is_informational()
73+
}
74+
75+
/// Checks if this status is in the 2xx (success) range.
76+
#[inline]
77+
pub fn is_success(&self) -> bool {
78+
self.inner.is_success()
79+
}
80+
81+
/// Checks if this status is in the 3xx (redirection) range.
82+
#[inline]
83+
pub fn is_redirection(&self) -> bool {
84+
self.inner.is_redirection()
85+
}
86+
87+
/// Checks if this status is in the 4xx (client error) range.
88+
#[inline]
89+
pub fn is_client_error(&self) -> bool {
90+
self.inner.is_client_error()
91+
}
92+
93+
/// Checks if this status is in the 5xx (server error) range.
94+
#[inline]
95+
pub fn is_server_error(&self) -> bool {
96+
self.inner.is_server_error()
97+
}
98+
}
99+
29100
/// Represents the HTTP methods supported by the `HttpClient`.
30101
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31102
#[cfg_attr(
@@ -63,18 +134,17 @@ impl Into<Method> for HttpMethod {
63134
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network")
64135
)]
65136
pub struct HttpResponse {
66-
/// The HTTP status code returned by the server.
67-
pub status: u16,
68-
/// The headers returned by the server as a map of key-value pairs.
137+
/// The HTTP status code.
138+
pub status: HttpStatus,
139+
/// The response headers as a map of key-value pairs.
69140
pub headers: HashMap<String, String>,
70-
/// The body of the response as raw bytes.
141+
/// The raw response body.
71142
pub body: Bytes,
72143
}
73144

74-
/// Represents errors that can occur when using the `HttpClient`.
145+
/// Errors returned by the HTTP client.
75146
///
76-
/// This enum provides variants for general HTTP errors and timeout errors,
77-
/// allowing for more granular error handling.
147+
/// Includes generic transport errors and timeouts.
78148
#[derive(thiserror::Error, Debug)]
79149
pub enum HttpClientError {
80150
#[error("HTTP error occurred: {0}")]
@@ -100,7 +170,10 @@ impl From<String> for HttpClientError {
100170
}
101171
}
102172

103-
/// A high-performance HTTP client with rate limiting and timeout capabilities.
173+
/// An HTTP client that supports rate limiting and timeouts.
174+
///
175+
/// Built on `reqwest` for async I/O. Allows per-endpoint and default quotas
176+
/// through [`RateLimiter`].
104177
///
105178
/// This struct is designed to handle HTTP requests efficiently, providing
106179
/// support for rate limiting, timeouts, and custom headers. The client is
@@ -152,19 +225,17 @@ impl HttpClient {
152225
}
153226
}
154227

155-
/// Send an HTTP request.
228+
/// Sends an HTTP request.
156229
///
157-
/// `method`: The HTTP method to call.
158-
/// `url`: The request is sent to this url.
159-
/// `headers`: The header key value pairs in the request.
160-
/// `body`: The bytes sent in the body of request.
161-
/// `keys`: The keys used for rate limiting the request.
230+
/// - `method`: The [`Method`] to use (GET, POST, etc.).
231+
/// - `url`: The target URL.
232+
/// - `headers`: Additional headers for this request.
233+
/// - `body`: Optional request body.
234+
/// - `keys`: Rate-limit keys to control request frequency.
235+
/// - `timeout_secs`: Optional request timeout in seconds.
162236
///
163237
/// # Example
164-
///
165-
/// When a request is made the URL should be split into all relevant keys within it.
166-
///
167-
/// For request /foo/bar, should pass keys ["foo/bar", "foo"] for rate limiting.
238+
/// If requesting `/foo/bar`, pass rate-limit keys `["foo/bar", "foo"]`.
168239
#[allow(clippy::too_many_arguments)]
169240
pub async fn request(
170241
&self,
@@ -184,9 +255,9 @@ impl HttpClient {
184255
}
185256
}
186257

187-
/// A high-performance `HttpClient` for HTTP requests.
258+
/// Internal implementation backing [`HttpClient`].
188259
///
189-
/// The client is backed by a hyper Client which keeps connections alive and
260+
/// The client is backed by a [`reqwest::Client`] which keeps connections alive and
190261
/// can be cloned cheaply. The client also has a list of header fields to
191262
/// extract from the response.
192263
///
@@ -199,13 +270,13 @@ pub struct InnerHttpClient {
199270
}
200271

201272
impl InnerHttpClient {
202-
/// Sends an HTTP request with the specified method, URL, headers, and body.
273+
/// Sends an HTTP request and returns an [`HttpResponse`].
203274
///
204-
/// - `method`: The HTTP method to use (e.g., GET, POST).
205-
/// - `url`: The URL to send the request to.
206-
/// - `headers`: A map of header key-value pairs to include in the request.
207-
/// - `body`: An optional body for the request, represented as a byte vector.
208-
/// - `timeout_secs`: An optional timeout for the request in seconds.
275+
/// - `method`: The HTTP method (e.g. GET, POST).
276+
/// - `url`: The target URL.
277+
/// - `headers`: Extra headers to send.
278+
/// - `body`: Optional request body.
279+
/// - `timeout_secs`: Optional request timeout in seconds.
209280
pub async fn send_request(
210281
&self,
211282
method: Method,
@@ -266,7 +337,7 @@ impl InnerHttpClient {
266337
.filter_map(|(key, val)| val.to_str().map(|v| (key, v)).ok())
267338
.map(|(k, v)| (k.clone(), v.to_owned()))
268339
.collect();
269-
let status = response.status().as_u16();
340+
let status = HttpStatus::new(response.status());
270341
let body = response.bytes().await.map_err(HttpClientError::from)?;
271342

272343
Ok(HttpResponse {
@@ -323,6 +394,14 @@ mod tests {
323394
.route("/post", post(|| async { StatusCode::OK }))
324395
.route("/patch", patch(|| async { StatusCode::OK }))
325396
.route("/delete", delete(|| async { StatusCode::OK }))
397+
.route("/notfound", get(|| async { StatusCode::NOT_FOUND }))
398+
.route(
399+
"/slow",
400+
get(|| async {
401+
tokio::time::sleep(Duration::from_secs(2)).await;
402+
"Eventually responded"
403+
}),
404+
)
326405
}
327406

328407
async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
@@ -350,7 +429,7 @@ mod tests {
350429
.await
351430
.unwrap();
352431

353-
assert_eq!(response.status, StatusCode::OK);
432+
assert!(response.status.is_success());
354433
assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
355434
}
356435

@@ -371,7 +450,7 @@ mod tests {
371450
.await
372451
.unwrap();
373452

374-
assert_eq!(response.status, StatusCode::OK);
453+
assert!(response.status.is_success());
375454
}
376455

377456
#[tokio::test]
@@ -405,7 +484,7 @@ mod tests {
405484
.await
406485
.unwrap();
407486

408-
assert_eq!(response.status, StatusCode::OK);
487+
assert!(response.status.is_success());
409488
}
410489

411490
#[tokio::test]
@@ -425,7 +504,7 @@ mod tests {
425504
.await
426505
.unwrap();
427506

428-
assert_eq!(response.status, StatusCode::OK);
507+
assert!(response.status.is_success());
429508
}
430509

431510
#[tokio::test]
@@ -445,6 +524,41 @@ mod tests {
445524
.await
446525
.unwrap();
447526

448-
assert_eq!(response.status, StatusCode::OK);
527+
assert!(response.status.is_success());
528+
}
529+
530+
#[tokio::test]
531+
async fn test_not_found() {
532+
let addr = start_test_server().await.unwrap();
533+
let url = format!("http://{addr}/notfound");
534+
let client = InnerHttpClient::default();
535+
536+
let response = client
537+
.send_request(reqwest::Method::GET, url, None, None, None)
538+
.await
539+
.unwrap();
540+
541+
assert!(response.status.is_client_error());
542+
assert_eq!(response.status.as_u16(), 404);
543+
}
544+
545+
#[tokio::test]
546+
async fn test_timeout() {
547+
let addr = start_test_server().await.unwrap();
548+
let url = format!("http://{addr}/slow");
549+
let client = InnerHttpClient::default();
550+
551+
// We'll set a 1-second timeout for a route that sleeps 2 seconds
552+
let result = client
553+
.send_request(reqwest::Method::GET, url, None, None, Some(1))
554+
.await;
555+
556+
match result {
557+
Err(HttpClientError::TimeoutError(msg)) => {
558+
println!("Got expected timeout error: {msg}");
559+
}
560+
Err(other) => panic!("Expected a timeout error, got: {other:?}"),
561+
Ok(resp) => panic!("Expected a timeout error, but got a successful response: {resp:?}"),
562+
}
449563
}
450564
}

nautilus_core/network/src/python/http.rs

+8-8
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ use std::{
1919
};
2020

2121
use bytes::Bytes;
22+
use nautilus_core::python::to_pyvalue_err;
2223
use pyo3::{create_exception, exceptions::PyException, prelude::*};
2324

2425
use crate::{
25-
http::{HttpClient, HttpClientError, HttpMethod, HttpResponse},
26+
http::{HttpClient, HttpClientError, HttpMethod, HttpResponse, HttpStatus},
2627
ratelimiter::quota::Quota,
2728
};
2829

@@ -54,19 +55,18 @@ impl HttpMethod {
5455
#[pymethods]
5556
impl HttpResponse {
5657
#[new]
57-
#[must_use]
58-
pub fn py_new(status: u16, body: Vec<u8>) -> Self {
59-
Self {
60-
status,
58+
pub fn py_new(status: u16, body: Vec<u8>) -> PyResult<Self> {
59+
Ok(Self {
60+
status: HttpStatus::from(status).map_err(to_pyvalue_err)?,
6161
headers: HashMap::new(),
6262
body: Bytes::from(body),
63-
}
63+
})
6464
}
6565

6666
#[getter]
6767
#[pyo3(name = "status")]
6868
pub const fn py_status(&self) -> u16 {
69-
self.status
69+
self.status.as_u16()
7070
}
7171

7272
#[getter]
@@ -118,7 +118,7 @@ impl HttpClient {
118118
Self::new(default_headers, header_keys, keyed_quotas, default_quota)
119119
}
120120

121-
/// Send an HTTP request.
121+
/// Sends an HTTP request.
122122
///
123123
/// `method`: The HTTP method to call.
124124
/// `url`: The request is sent to this url.

0 commit comments

Comments
 (0)