Skip to main content

nautilus_derive/common/
retry.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//! Retry classification for the Derive adapter.
17//!
18//! Splits [`DeriveHttpError`] and [`DeriveWsError`] into retryable, terminal,
19//! and fatal categories. The HTTP client routes errors through these helpers
20//! when driving [`nautilus_network::retry::RetryManager`]; the adapter-level
21//! [`crate::common::error::DeriveError`] reuses them for `is_retryable` /
22//! `is_fatal`.
23
24use nautilus_network::retry::RetryConfig;
25
26use crate::{http::DeriveHttpError, websocket::DeriveWsError};
27
28/// Builds a [`RetryConfig`] for Derive HTTP calls from the adapter's config
29/// fields.
30///
31/// `max_retries` is the budget; `initial_delay_ms` and `max_delay_ms` bound
32/// the exponential backoff. Other fields use values tuned for Derive: a
33/// 60-second per-attempt timeout (REST endpoints can return slow during venue
34/// load), a 3-minute overall budget, and 1s of jitter to avoid synchronizing
35/// retry storms across processes.
36#[must_use]
37pub fn http_retry_config(
38    max_retries: u32,
39    initial_delay_ms: u64,
40    max_delay_ms: u64,
41) -> RetryConfig {
42    RetryConfig {
43        max_retries,
44        initial_delay_ms,
45        max_delay_ms,
46        backoff_factor: 2.0,
47        jitter_ms: 1_000,
48        operation_timeout_ms: Some(60_000),
49        immediate_first: false,
50        max_elapsed_ms: Some(180_000),
51    }
52}
53
54/// Returns `true` for HTTP errors that can safely be retried with backoff.
55///
56/// Retryable categories:
57///
58/// - Transport failures (connection reset, timeout, DNS).
59/// - HTTP 5xx and 408 / 429.
60/// - JSON-RPC `Server error` codes in the `-32099..=-32000` range.
61///
62/// Everything else (validation, signed-fee-too-low, insufficient-margin,
63/// auth failure) is terminal and must not be retried.
64#[must_use]
65pub fn should_retry_http_error(error: &DeriveHttpError) -> bool {
66    match error {
67        DeriveHttpError::Transport(_) => true,
68        DeriveHttpError::Http { status, .. } => is_retryable_status(*status),
69        DeriveHttpError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
70        DeriveHttpError::MissingResult { .. }
71        | DeriveHttpError::Decode(_)
72        | DeriveHttpError::Serde(_)
73        | DeriveHttpError::Auth(_)
74        | DeriveHttpError::MissingCredentials { .. } => false,
75    }
76}
77
78/// Returns `true` for HTTP errors that signal a fatal session state requiring
79/// operator intervention (auth header rejection, session key deregistered,
80/// subaccount withdrawn).
81///
82/// Fatal errors are a subset of non-retryable: they should also short-circuit
83/// any caller-level retry budgets.
84#[must_use]
85pub fn is_fatal_http_error(error: &DeriveHttpError) -> bool {
86    match error {
87        DeriveHttpError::Auth(_) | DeriveHttpError::MissingCredentials { .. } => true,
88        DeriveHttpError::Http { status, .. } => matches!(*status, 401 | 403),
89        DeriveHttpError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
90        _ => false,
91    }
92}
93
94/// Returns `true` for WebSocket errors that can safely be retried.
95#[must_use]
96pub fn should_retry_ws_error(error: &DeriveWsError) -> bool {
97    match error {
98        DeriveWsError::Transport(_)
99        | DeriveWsError::RequestCancelled { .. }
100        | DeriveWsError::Timeout { .. } => true,
101        DeriveWsError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
102        DeriveWsError::NotConnected
103        | DeriveWsError::Serde(_)
104        | DeriveWsError::Auth(_)
105        | DeriveWsError::MissingCredentials { .. } => false,
106    }
107}
108
109/// Returns `true` for WebSocket errors that indicate a fatal session state.
110#[must_use]
111pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
112    match error {
113        DeriveWsError::Auth(_) | DeriveWsError::MissingCredentials { .. } => true,
114        DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
115        _ => false,
116    }
117}
118
119/// Classifies an HTTP status code.
120#[must_use]
121fn is_retryable_status(status: u16) -> bool {
122    matches!(status, 408 | 429) || (500..600).contains(&status)
123}
124
125/// Classifies a JSON-RPC error code.
126///
127/// Derive does not publish a stable retry classification for its venue codes,
128/// so the policy is conservative: only generic transient categories retry
129/// (the JSON-RPC `Server error` range, plus internal error `-32603` which the
130/// venue uses for transient backend faults). Signed-action rejections such as
131/// `signed_max_fee_too_low` and `insufficient_margin` arrive as standard
132/// invalid-params errors and are intentionally not retried; the caller has to
133/// reprice or refund collateral before resubmission.
134#[must_use]
135pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
136    code == -32603 || (-32099..=-32000).contains(&code)
137}
138
139/// Returns `true` only for JSON-RPC codes where the *outcome of a state-changing
140/// write* is genuinely ambiguous: the venue may have processed the request and
141/// merely failed to respond. Strictly narrower than [`is_retryable_jsonrpc_code`].
142///
143/// The retry classifier covers transient transport-style failures, including
144/// venue-defined codes like `-32000 Rate limit exceeded`. Rate-limit (and most
145/// other Derive server errors) is a **definitive** rejection: the gateway threw
146/// the request out before the matching engine saw it. Treating those as
147/// ambiguous leaves the order hanging in `Submitted` forever because no WS
148/// frame will come for an order that was never placed.
149///
150/// The current entry is `-32603` (generic JSON-RPC internal error): the only
151/// code where the venue's own process is known to have run for some unknown
152/// distance before failing. Extend this list only with evidence that a code
153/// genuinely leaves outcome unknown.
154#[must_use]
155pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
156    code == -32603
157}
158
159/// Returns `true` for non-JSON-RPC HTTP statuses where a state-changing
160/// write failed before the matching engine could accept it.
161///
162/// HTTP 4xx responses come from gateway, auth, throttling, or request-shape
163/// rejection paths. They are definitive for submit/cancel/modify outcomes,
164/// even when an idempotent read would retry some of them. HTTP 5xx and
165/// transport failures remain ambiguous for writes.
166///
167/// Retained for the HTTP order-write path (the execution client now writes over
168/// the WebSocket and classifies outcomes via `is_write_outcome_ambiguous_ws`).
169#[must_use]
170pub fn is_write_outcome_definitive_http_status(status: u16) -> bool {
171    (400..500).contains(&status)
172}
173
174/// Returns `true` when a WebSocket write's outcome is unknown (sent, but no
175/// clear venue verdict), so the caller emits no terminal event and lets
176/// reconciliation settle the order. `JsonRpc` defers to the shared code policy
177/// in [`is_write_outcome_ambiguous_jsonrpc`] (only `-32603`).
178///
179/// Two non-obvious calls: `Serde` is ambiguous because it is a failure to decode
180/// the *response* (the request cannot fail to serialize), so the action may have
181/// been processed; `NotConnected` is definitive because it is returned before
182/// the frame is sent, so the order was never placed.
183#[must_use]
184pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
185    match error {
186        DeriveWsError::Transport(_)
187        | DeriveWsError::RequestCancelled { .. }
188        | DeriveWsError::Timeout { .. }
189        | DeriveWsError::Serde(_) => true,
190        DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
191        DeriveWsError::NotConnected
192        | DeriveWsError::Auth(_)
193        | DeriveWsError::MissingCredentials { .. } => false,
194    }
195}
196
197/// Classifies a JSON-RPC error code as fatal. Derive currently does not
198/// expose a dedicated session-killed code, so this only flags the standard
199/// invalid-request shape used for unrecoverable framing problems.
200#[must_use]
201fn is_fatal_jsonrpc_code(code: i64) -> bool {
202    matches!(code, -32600 | -32700)
203}
204
205#[cfg(test)]
206mod tests {
207    use rstest::rstest;
208    use serde_json::Value;
209
210    use super::*;
211
212    #[rstest]
213    fn test_transport_error_retryable() {
214        let err = DeriveHttpError::transport("conn reset");
215        assert!(should_retry_http_error(&err));
216        assert!(!is_fatal_http_error(&err));
217    }
218
219    #[rstest]
220    #[case(500, true)]
221    #[case(502, true)]
222    #[case(503, true)]
223    #[case(504, true)]
224    #[case(429, true)]
225    #[case(408, true)]
226    #[case(400, false)]
227    #[case(404, false)]
228    #[case(409, false)]
229    #[case(422, false)]
230    fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
231        let err = DeriveHttpError::http(status, "body");
232        assert_eq!(should_retry_http_error(&err), retryable);
233    }
234
235    #[rstest]
236    #[case(401)]
237    #[case(403)]
238    fn test_http_auth_status_is_fatal(#[case] status: u16) {
239        let err = DeriveHttpError::http(status, "Unauthorized");
240        assert!(is_fatal_http_error(&err));
241        assert!(!should_retry_http_error(&err));
242    }
243
244    #[rstest]
245    fn test_jsonrpc_invalid_params_not_retryable() {
246        // Venue surfaces `signed_max_fee_too_low`, `insufficient_margin`, etc.
247        // as standard JSON-RPC -32602 invalid-params payloads. These reflect
248        // caller-side state and must never be retried.
249        let err = DeriveHttpError::JsonRpc {
250            code: -32602,
251            message: "signed_max_fee_too_low".into(),
252            data: None,
253        };
254        assert!(!should_retry_http_error(&err));
255        assert!(!is_fatal_http_error(&err));
256    }
257
258    #[rstest]
259    fn test_jsonrpc_server_error_range_retryable() {
260        let err = DeriveHttpError::JsonRpc {
261            code: -32050,
262            message: "Server busy".into(),
263            data: None,
264        };
265        assert!(should_retry_http_error(&err));
266    }
267
268    #[rstest]
269    fn test_jsonrpc_internal_error_retryable() {
270        let err = DeriveHttpError::JsonRpc {
271            code: -32603,
272            message: "Internal error".into(),
273            data: None,
274        };
275        assert!(should_retry_http_error(&err));
276    }
277
278    #[rstest]
279    #[case(400, true)]
280    #[case(401, true)]
281    #[case(403, true)]
282    #[case(408, true)]
283    #[case(429, true)]
284    #[case(500, false)]
285    #[case(503, false)]
286    fn test_http_status_write_outcome_classification(
287        #[case] status: u16,
288        #[case] definitive: bool,
289    ) {
290        assert_eq!(is_write_outcome_definitive_http_status(status), definitive);
291    }
292
293    #[rstest]
294    fn test_jsonrpc_invalid_request_is_fatal() {
295        let err = DeriveHttpError::JsonRpc {
296            code: -32600,
297            message: "Invalid request".into(),
298            data: Some(Value::Null),
299        };
300        assert!(is_fatal_http_error(&err));
301        assert!(!should_retry_http_error(&err));
302    }
303
304    #[rstest]
305    fn test_missing_credentials_terminal() {
306        let err = DeriveHttpError::MissingCredentials {
307            method: "private/order".into(),
308        };
309        assert!(!should_retry_http_error(&err));
310        assert!(is_fatal_http_error(&err));
311    }
312
313    #[rstest]
314    fn test_ws_transport_retryable() {
315        let err = DeriveWsError::transport("send failed");
316        assert!(should_retry_ws_error(&err));
317    }
318
319    #[rstest]
320    fn test_ws_not_connected_terminal() {
321        let err = DeriveWsError::NotConnected;
322        assert!(!should_retry_ws_error(&err));
323        assert!(!is_fatal_ws_error(&err));
324    }
325
326    #[rstest]
327    fn test_ws_request_cancelled_retryable() {
328        // The handler drops the oneshot on reconnect; the caller can re-issue
329        // after the new session is up.
330        let err = DeriveWsError::RequestCancelled {
331            method: "subscribe".into(),
332        };
333        assert!(should_retry_ws_error(&err));
334    }
335
336    #[rstest]
337    fn test_ws_timeout_retryable_not_fatal() {
338        let err = DeriveWsError::Timeout {
339            method: "private/order".into(),
340        };
341        assert!(should_retry_ws_error(&err));
342        assert!(!is_fatal_ws_error(&err));
343    }
344
345    #[rstest]
346    fn test_ws_write_outcome_ambiguous_classification() {
347        // Sent-but-unconfirmed outcomes are ambiguous; everything else is a
348        // definitive rejection the caller can surface as a terminal event.
349        let ambiguous = [
350            DeriveWsError::transport("send failed"),
351            DeriveWsError::RequestCancelled {
352                method: "private/order".into(),
353            },
354            DeriveWsError::Timeout {
355                method: "private/order".into(),
356            },
357            // A response the client cannot decode: the action may have been
358            // processed, so await reconciliation rather than reject.
359            DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
360            DeriveWsError::JsonRpc {
361                code: -32603,
362                message: "Internal error".into(),
363                data: None,
364            },
365        ];
366        let definitive = [
367            DeriveWsError::NotConnected,
368            DeriveWsError::JsonRpc {
369                code: -32602,
370                message: "signed_max_fee_too_low".into(),
371                data: None,
372            },
373            DeriveWsError::MissingCredentials {
374                operation: "private/order".into(),
375            },
376        ];
377
378        for err in &ambiguous {
379            assert!(
380                is_write_outcome_ambiguous_ws(err),
381                "expected ambiguous: {err}"
382            );
383        }
384
385        for err in &definitive {
386            assert!(
387                !is_write_outcome_ambiguous_ws(err),
388                "expected definitive: {err}",
389            );
390        }
391    }
392}