Skip to main content

nautilus_lighter/http/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP error taxonomy for Lighter REST responses.
17
18use std::time::Duration;
19
20use nautilus_network::http::HttpClientError;
21use thiserror::Error;
22
23/// Result alias for Lighter HTTP operations.
24pub type LighterHttpResult<T> = Result<T, LighterHttpError>;
25
26/// Errors emitted by the Lighter HTTP client.
27#[derive(Debug, Clone, Error)]
28pub enum LighterHttpError {
29    /// Network-level failure (transport, DNS, TLS).
30    #[error("network error: {0}")]
31    Network(String),
32    /// HTTP-level failure with status code, body, and optional minimum retry delay.
33    #[error("HTTP {status}: {body}")]
34    Http {
35        status: u16,
36        body: String,
37        retry_after: Option<Duration>,
38    },
39    /// Rate limit exceeded, with an optional minimum retry delay.
40    #[error("rate limit exceeded: {message}")]
41    RateLimit {
42        message: String,
43        retry_after: Option<Duration>,
44    },
45    /// Venue returned a structured error code.
46    #[error("venue error {code}: {message}")]
47    Venue { code: i64, message: String },
48    /// Historical pagination stopped before satisfying the request.
49    #[error("{data_type} history incomplete after {pages} pages")]
50    HistoryIncomplete {
51        data_type: &'static str,
52        pages: usize,
53    },
54    /// Failed to parse a venue response.
55    #[error("parse error: {0}")]
56    Parse(String),
57}
58
59impl LighterHttpError {
60    pub(super) const fn retry_after(&self) -> Option<Duration> {
61        match self {
62            Self::Http { retry_after, .. } | Self::RateLimit { retry_after, .. } => *retry_after,
63            _ => None,
64        }
65    }
66}
67
68impl From<HttpClientError> for LighterHttpError {
69    fn from(error: HttpClientError) -> Self {
70        Self::Network(error.to_string())
71    }
72}
73
74impl From<serde_json::Error> for LighterHttpError {
75    fn from(error: serde_json::Error) -> Self {
76        Self::Parse(error.to_string())
77    }
78}
79
80impl From<anyhow::Error> for LighterHttpError {
81    fn from(error: anyhow::Error) -> Self {
82        Self::Parse(error.to_string())
83    }
84}
85
86/// Returns `true` if a request producing this error should be retried.
87///
88/// Retryable shapes are transport-layer failures, server-side 5xx, and rate limits.
89/// Venue-semantic and incomplete-history errors are surfaced unchanged.
90#[must_use]
91pub fn should_retry_lighter_http_error(error: &LighterHttpError) -> bool {
92    match error {
93        LighterHttpError::Network(_) | LighterHttpError::RateLimit { .. } => true,
94        LighterHttpError::Http { status, .. } => *status >= 500,
95        LighterHttpError::Venue { .. }
96        | LighterHttpError::HistoryIncomplete { .. }
97        | LighterHttpError::Parse(_) => false,
98    }
99}
100
101/// Constructs a transport-shaped error for failures synthesized by retry machinery.
102#[must_use]
103pub fn create_lighter_http_timeout_error(msg: String) -> LighterHttpError {
104    LighterHttpError::Network(msg)
105}
106
107#[cfg(test)]
108mod tests {
109    use rstest::rstest;
110
111    use super::*;
112
113    #[rstest]
114    #[case::network_retries(LighterHttpError::Network("dns failure".into()), true)]
115    #[case::rate_limit_retries(LighterHttpError::RateLimit { message: "429".into(), retry_after: None }, true)]
116    #[case::server_5xx_retries(LighterHttpError::Http { status: 503, body: "busy".into(), retry_after: None }, true)]
117    #[case::server_500_retries(LighterHttpError::Http { status: 500, body: "boom".into(), retry_after: None }, true)]
118    #[case::client_400_does_not_retry(LighterHttpError::Http { status: 400, body: "bad".into(), retry_after: None }, false)]
119    #[case::client_404_does_not_retry(LighterHttpError::Http { status: 404, body: "missing".into(), retry_after: None }, false)]
120    #[case::venue_does_not_retry(LighterHttpError::Venue { code: 20001, message: "invalid".into() }, false)]
121    #[case::incomplete_history_does_not_retry(
122        LighterHttpError::HistoryIncomplete {
123            data_type: "funding rate",
124            pages: 500,
125        },
126        false
127    )]
128    #[case::parse_does_not_retry(LighterHttpError::Parse("bad json".into()), false)]
129    fn test_should_retry_lighter_http_error(
130        #[case] error: LighterHttpError,
131        #[case] expected: bool,
132    ) {
133        assert_eq!(should_retry_lighter_http_error(&error), expected);
134    }
135}