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 classifiers
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, RetryError};
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 transient HTTP errors eligible for read retries with backoff.
55///
56/// This classifier does not establish the outcome of a state-changing request.
57///
58/// Retryable categories:
59///
60/// - Transport failures (connection reset, timeout, DNS).
61/// - HTTP 5xx and 408 / 429.
62/// - JSON-RPC `Server error` codes in the `-32099..=-32000` range.
63/// - JSON-RPC internal error (`-32603`).
64/// - Read-backend unavailability (`9002`).
65///
66/// Everything else (validation, signed-fee-too-low, insufficient-margin,
67/// auth failure) is non-retryable under this policy.
68#[must_use]
69pub fn should_retry_http_error(error: &DeriveHttpError) -> bool {
70    match error {
71        DeriveHttpError::Transport(_) => true,
72        DeriveHttpError::Retry(RetryError::OperationTimeout { .. }) => true,
73        DeriveHttpError::Http { status, .. } => is_retryable_status(*status),
74        DeriveHttpError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
75        DeriveHttpError::MissingResult { .. }
76        | DeriveHttpError::Decode(_)
77        | DeriveHttpError::Serde(_)
78        | DeriveHttpError::Auth(_)
79        | DeriveHttpError::MissingCredentials { .. }
80        | DeriveHttpError::Retry(_) => false,
81    }
82}
83
84/// Returns `true` for HTTP errors that signal a fatal session state requiring
85/// operator intervention (auth header rejection, session key deregistered,
86/// subaccount withdrawn).
87///
88/// Fatal errors are a subset of non-retryable: they should also short-circuit
89/// any caller-level retry budgets.
90#[must_use]
91pub fn is_fatal_http_error(error: &DeriveHttpError) -> bool {
92    match error {
93        DeriveHttpError::Auth(_) | DeriveHttpError::MissingCredentials { .. } => true,
94        DeriveHttpError::Http { status, .. } => matches!(*status, 401 | 403),
95        DeriveHttpError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
96        _ => false,
97    }
98}
99
100/// Returns `true` for transient WebSocket errors eligible for recovery retries.
101///
102/// A timeout or transport error does not authorize replaying an order write.
103#[must_use]
104pub fn should_retry_ws_error(error: &DeriveWsError) -> bool {
105    match error {
106        DeriveWsError::Transport(_)
107        | DeriveWsError::RequestCancelled { .. }
108        | DeriveWsError::Timeout { .. }
109        | DeriveWsError::RateLimited { .. } => true,
110        DeriveWsError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
111        DeriveWsError::NotConnected
112        | DeriveWsError::Serde(_)
113        | DeriveWsError::Auth(_)
114        | DeriveWsError::Authentication { .. }
115        | DeriveWsError::Subscription { .. }
116        | DeriveWsError::MissingCredentials { .. } => false,
117    }
118}
119
120/// Returns `true` for WebSocket errors that indicate a fatal session state.
121#[must_use]
122pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
123    match error {
124        DeriveWsError::Auth(_)
125        | DeriveWsError::Authentication { .. }
126        | DeriveWsError::MissingCredentials { .. } => true,
127        DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
128        _ => false,
129    }
130}
131
132/// Classifies an HTTP status code.
133#[must_use]
134fn is_retryable_status(status: u16) -> bool {
135    matches!(status, 408 | 429) || (500..600).contains(&status)
136}
137
138/// Classifies a JSON-RPC error code.
139///
140/// Read retries cover backend unavailability and generic transient failures.
141/// Confirmation timeouts do not authorize replaying a state-changing command.
142#[must_use]
143pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
144    matches!(code, -32603 | 9002) || (-32099..=-32000).contains(&code)
145}
146
147/// Returns `true` only for JSON-RPC codes where the *outcome of a state-changing
148/// write* is ambiguous: the venue may have processed the request and merely
149/// failed to respond. Retry eligibility does not establish a write's outcome.
150///
151/// The retry classifier covers transient transport-style failures, including
152/// venue-defined codes like `-32000 Rate limit exceeded`. Rate-limit (and most
153/// other Derive server errors) is a **definitive** rejection: the gateway threw
154/// the request out before the matching engine saw it. Treating those as
155/// ambiguous leaves the order hanging in `Submitted` forever because no WS
156/// frame will come for an order that was never placed.
157///
158/// Internal error `-32603` and confirmation timeouts `9000` / `9001` leave
159/// acceptance unresolved. The venue requires querying those orders before
160/// resubmission.
161#[must_use]
162pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
163    matches!(code, -32603 | 9000 | 9001)
164}
165
166/// Returns `true` when a WebSocket write's outcome is unknown (sent, but no
167/// clear venue verdict), so the caller emits no terminal event and lets
168/// reconciliation settle the order. `JsonRpc` defers to the shared code policy
169/// in [`is_write_outcome_ambiguous_jsonrpc`].
170///
171/// Two non-obvious calls: `Serde` is ambiguous because it is a failure to decode
172/// the *response* (the request cannot fail to serialize), so the action may have
173/// been processed; `NotConnected` is definitive because it is returned before
174/// the frame is sent, so the order was never placed.
175#[must_use]
176pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
177    match error {
178        DeriveWsError::Transport(_)
179        | DeriveWsError::RequestCancelled { .. }
180        | DeriveWsError::Timeout { .. }
181        | DeriveWsError::Serde(_) => true,
182        DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
183        DeriveWsError::NotConnected
184        | DeriveWsError::Auth(_)
185        | DeriveWsError::Authentication { .. }
186        | DeriveWsError::Subscription { .. }
187        | DeriveWsError::RateLimited { .. }
188        | DeriveWsError::MissingCredentials { .. } => false,
189    }
190}
191
192/// Classifies unrecoverable framing and account, signer, or session failures.
193#[must_use]
194fn is_fatal_jsonrpc_code(code: i64) -> bool {
195    matches!(
196        code,
197        -32600
198            | -32700
199            | 14000
200            | 14001
201            | 14013
202            | 14014
203            | 14020
204            | 14021
205            | 14023
206            | 14026
207            | 14030
208            | 14031
209    )
210}
211
212#[cfg(test)]
213mod tests {
214    use rstest::rstest;
215    use serde_json::Value;
216
217    use super::*;
218
219    #[rstest]
220    #[case(14000, true, false)]
221    #[case(14001, true, false)]
222    #[case(14013, true, false)]
223    #[case(14014, true, false)]
224    #[case(14020, true, false)]
225    #[case(14021, true, false)]
226    #[case(14023, true, false)]
227    #[case(14026, true, false)]
228    #[case(14030, true, false)]
229    #[case(14031, true, false)]
230    #[case(9002, false, true)]
231    #[case(9000, false, false)]
232    #[case(12001, false, false)]
233    fn test_venue_session_error_classification(
234        #[case] code: i64,
235        #[case] fatal: bool,
236        #[case] retry: bool,
237    ) {
238        let http = DeriveHttpError::JsonRpc {
239            code,
240            message: "opaque".to_string(),
241            data: None,
242            retry_after: None,
243        };
244
245        let ws = DeriveWsError::JsonRpc {
246            code,
247            message: "opaque".to_string(),
248            data: None,
249        };
250
251        assert_eq!(is_fatal_http_error(&http), fatal);
252        assert_eq!(is_fatal_ws_error(&ws), fatal);
253        assert_eq!(should_retry_http_error(&http), retry);
254        assert_eq!(should_retry_ws_error(&ws), retry);
255    }
256
257    #[rstest]
258    fn test_transport_error_retryable() {
259        let err = DeriveHttpError::transport("conn reset");
260        assert!(should_retry_http_error(&err));
261        assert!(!is_fatal_http_error(&err));
262    }
263
264    #[rstest]
265    #[case(500, true)]
266    #[case(502, true)]
267    #[case(503, true)]
268    #[case(504, true)]
269    #[case(429, true)]
270    #[case(408, true)]
271    #[case(400, false)]
272    #[case(404, false)]
273    #[case(409, false)]
274    #[case(422, false)]
275    fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
276        let err = DeriveHttpError::http(status, "body");
277        assert_eq!(should_retry_http_error(&err), retryable);
278    }
279
280    #[rstest]
281    #[case(401)]
282    #[case(403)]
283    fn test_http_auth_status_is_fatal(#[case] status: u16) {
284        let err = DeriveHttpError::http(status, "Unauthorized");
285        assert!(is_fatal_http_error(&err));
286        assert!(!should_retry_http_error(&err));
287    }
288
289    #[rstest]
290    fn test_jsonrpc_invalid_params_not_retryable() {
291        // Venue surfaces `signed_max_fee_too_low`, `insufficient_margin`, etc.
292        // as standard JSON-RPC -32602 invalid-params payloads. These reflect
293        // caller-side state and must never be retried.
294        let err = DeriveHttpError::JsonRpc {
295            code: -32602,
296            message: "signed_max_fee_too_low".into(),
297            data: None,
298            retry_after: None,
299        };
300
301        assert!(!should_retry_http_error(&err));
302        assert!(!is_fatal_http_error(&err));
303    }
304
305    #[rstest]
306    fn test_jsonrpc_server_error_range_retryable() {
307        let err = DeriveHttpError::JsonRpc {
308            code: -32050,
309            message: "Server busy".into(),
310            data: None,
311            retry_after: None,
312        };
313
314        assert!(should_retry_http_error(&err));
315    }
316
317    #[rstest]
318    fn test_jsonrpc_internal_error_retryable() {
319        let err = DeriveHttpError::JsonRpc {
320            code: -32603,
321            message: "Internal error".into(),
322            data: None,
323            retry_after: None,
324        };
325
326        assert!(should_retry_http_error(&err));
327    }
328
329    #[rstest]
330    fn test_jsonrpc_invalid_request_is_fatal() {
331        let err = DeriveHttpError::JsonRpc {
332            code: -32600,
333            message: "Invalid request".into(),
334            data: Some(Value::Null),
335            retry_after: None,
336        };
337
338        assert!(is_fatal_http_error(&err));
339        assert!(!should_retry_http_error(&err));
340    }
341
342    #[rstest]
343    fn test_missing_credentials_terminal() {
344        let err = DeriveHttpError::MissingCredentials {
345            method: "private/order".into(),
346        };
347
348        assert!(!should_retry_http_error(&err));
349        assert!(is_fatal_http_error(&err));
350    }
351
352    #[rstest]
353    fn test_ws_transport_retryable() {
354        let err = DeriveWsError::transport("send failed");
355        assert!(should_retry_ws_error(&err));
356    }
357
358    #[rstest]
359    fn test_ws_not_connected_terminal() {
360        let err = DeriveWsError::NotConnected;
361        assert!(!should_retry_ws_error(&err));
362        assert!(!is_fatal_ws_error(&err));
363    }
364
365    #[rstest]
366    fn test_ws_request_cancelled_retryable() {
367        // The handler drops the oneshot on reconnect; the caller can re-issue
368        // after the new session is up.
369        let err = DeriveWsError::RequestCancelled {
370            method: "subscribe".into(),
371        };
372
373        assert!(should_retry_ws_error(&err));
374    }
375
376    #[rstest]
377    fn test_ws_timeout_retryable_not_fatal() {
378        let err = DeriveWsError::Timeout {
379            method: "private/order".into(),
380        };
381
382        assert!(should_retry_ws_error(&err));
383        assert!(!is_fatal_ws_error(&err));
384    }
385
386    #[rstest]
387    fn test_ws_authentication_and_subscription_failures_are_terminal() {
388        let authentication = DeriveWsError::Authentication {
389            operation: "private/order".into(),
390            reason: "session recovery failed".into(),
391        };
392
393        let subscription = DeriveWsError::Subscription {
394            details: "30769.trades: unauthorized".into(),
395        };
396
397        assert!(!should_retry_ws_error(&authentication));
398        assert!(is_fatal_ws_error(&authentication));
399        assert!(!should_retry_ws_error(&subscription));
400        assert!(!is_fatal_ws_error(&subscription));
401    }
402
403    #[rstest]
404    fn test_ws_write_outcome_ambiguous_classification() {
405        // Sent-but-unconfirmed outcomes are ambiguous; everything else is a
406        // definitive rejection the caller can surface as a terminal event.
407        let ambiguous = [
408            DeriveWsError::transport("send failed"),
409            DeriveWsError::RequestCancelled {
410                method: "private/order".into(),
411            },
412            DeriveWsError::Timeout {
413                method: "private/order".into(),
414            },
415            // A response the client cannot decode: the action may have been
416            // processed, so await reconciliation rather than reject.
417            DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
418            DeriveWsError::JsonRpc {
419                code: -32603,
420                message: "Internal error".into(),
421                data: None,
422            },
423        ];
424
425        let definitive = [
426            DeriveWsError::NotConnected,
427            DeriveWsError::JsonRpc {
428                code: -32602,
429                message: "signed_max_fee_too_low".into(),
430                data: None,
431            },
432            DeriveWsError::MissingCredentials {
433                operation: "private/order".into(),
434            },
435            DeriveWsError::Authentication {
436                operation: "private/order".into(),
437                reason: "session recovery failed".into(),
438            },
439            DeriveWsError::Subscription {
440                details: "30769.trades: unauthorized".into(),
441            },
442        ];
443
444        for err in &ambiguous {
445            assert!(
446                is_write_outcome_ambiguous_ws(err),
447                "expected ambiguous: {err}"
448            );
449        }
450
451        for err in &definitive {
452            assert!(
453                !is_write_outcome_ambiguous_ws(err),
454                "expected definitive: {err}",
455            );
456        }
457    }
458}