18
18
use std:: { collections:: HashMap , hash:: Hash , str:: FromStr , sync:: Arc , time:: Duration } ;
19
19
20
20
use bytes:: Bytes ;
21
- use http:: HeaderValue ;
21
+ use http:: { status :: InvalidStatusCode , HeaderValue , StatusCode } ;
22
22
use reqwest:: {
23
23
header:: { HeaderMap , HeaderName } ,
24
24
Method , Response , Url ,
25
25
} ;
26
26
27
27
use crate :: ratelimiter:: { clock:: MonotonicClock , quota:: Quota , RateLimiter } ;
28
28
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
+
29
100
/// Represents the HTTP methods supported by the `HttpClient`.
30
101
#[ derive( Clone , Copy , Debug , PartialEq , Eq , Hash ) ]
31
102
#[ cfg_attr(
@@ -63,18 +134,17 @@ impl Into<Method> for HttpMethod {
63
134
pyo3:: pyclass( module = "nautilus_trader.core.nautilus_pyo3.network" )
64
135
) ]
65
136
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.
69
140
pub headers : HashMap < String , String > ,
70
- /// The body of the response as raw bytes .
141
+ /// The raw response body .
71
142
pub body : Bytes ,
72
143
}
73
144
74
- /// Represents errors that can occur when using the `HttpClient` .
145
+ /// Errors returned by the HTTP client .
75
146
///
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.
78
148
#[ derive( thiserror:: Error , Debug ) ]
79
149
pub enum HttpClientError {
80
150
#[ error( "HTTP error occurred: {0}" ) ]
@@ -100,7 +170,10 @@ impl From<String> for HttpClientError {
100
170
}
101
171
}
102
172
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`].
104
177
///
105
178
/// This struct is designed to handle HTTP requests efficiently, providing
106
179
/// support for rate limiting, timeouts, and custom headers. The client is
@@ -152,19 +225,17 @@ impl HttpClient {
152
225
}
153
226
}
154
227
155
- /// Send an HTTP request.
228
+ /// Sends an HTTP request.
156
229
///
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.
162
236
///
163
237
/// # 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"]`.
168
239
#[ allow( clippy:: too_many_arguments) ]
169
240
pub async fn request (
170
241
& self ,
@@ -184,9 +255,9 @@ impl HttpClient {
184
255
}
185
256
}
186
257
187
- /// A high-performance `HttpClient` for HTTP requests .
258
+ /// Internal implementation backing [ `HttpClient`] .
188
259
///
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
190
261
/// can be cloned cheaply. The client also has a list of header fields to
191
262
/// extract from the response.
192
263
///
@@ -199,13 +270,13 @@ pub struct InnerHttpClient {
199
270
}
200
271
201
272
impl InnerHttpClient {
202
- /// Sends an HTTP request with the specified method, URL, headers, and body .
273
+ /// Sends an HTTP request and returns an [`HttpResponse`] .
203
274
///
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.
209
280
pub async fn send_request (
210
281
& self ,
211
282
method : Method ,
@@ -266,7 +337,7 @@ impl InnerHttpClient {
266
337
. filter_map ( |( key, val) | val. to_str ( ) . map ( |v| ( key, v) ) . ok ( ) )
267
338
. map ( |( k, v) | ( k. clone ( ) , v. to_owned ( ) ) )
268
339
. collect ( ) ;
269
- let status = response. status ( ) . as_u16 ( ) ;
340
+ let status = HttpStatus :: new ( response. status ( ) ) ;
270
341
let body = response. bytes ( ) . await . map_err ( HttpClientError :: from) ?;
271
342
272
343
Ok ( HttpResponse {
@@ -323,6 +394,14 @@ mod tests {
323
394
. route ( "/post" , post ( || async { StatusCode :: OK } ) )
324
395
. route ( "/patch" , patch ( || async { StatusCode :: OK } ) )
325
396
. 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
+ )
326
405
}
327
406
328
407
async fn start_test_server ( ) -> Result < SocketAddr , Box < dyn std:: error:: Error + Send + Sync > > {
@@ -350,7 +429,7 @@ mod tests {
350
429
. await
351
430
. unwrap ( ) ;
352
431
353
- assert_eq ! ( response. status, StatusCode :: OK ) ;
432
+ assert ! ( response. status. is_success ( ) ) ;
354
433
assert_eq ! ( String :: from_utf8_lossy( & response. body) , "hello-world!" ) ;
355
434
}
356
435
@@ -371,7 +450,7 @@ mod tests {
371
450
. await
372
451
. unwrap ( ) ;
373
452
374
- assert_eq ! ( response. status, StatusCode :: OK ) ;
453
+ assert ! ( response. status. is_success ( ) ) ;
375
454
}
376
455
377
456
#[ tokio:: test]
@@ -405,7 +484,7 @@ mod tests {
405
484
. await
406
485
. unwrap ( ) ;
407
486
408
- assert_eq ! ( response. status, StatusCode :: OK ) ;
487
+ assert ! ( response. status. is_success ( ) ) ;
409
488
}
410
489
411
490
#[ tokio:: test]
@@ -425,7 +504,7 @@ mod tests {
425
504
. await
426
505
. unwrap ( ) ;
427
506
428
- assert_eq ! ( response. status, StatusCode :: OK ) ;
507
+ assert ! ( response. status. is_success ( ) ) ;
429
508
}
430
509
431
510
#[ tokio:: test]
@@ -445,6 +524,41 @@ mod tests {
445
524
. await
446
525
. unwrap ( ) ;
447
526
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
+ }
449
563
}
450
564
}
0 commit comments