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::Authentication { .. }
106        | DeriveWsError::Subscription { .. }
107        | DeriveWsError::MissingCredentials { .. } => false,
108    }
109}
110
111/// Returns `true` for WebSocket errors that indicate a fatal session state.
112#[must_use]
113pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
114    match error {
115        DeriveWsError::Auth(_)
116        | DeriveWsError::Authentication { .. }
117        | DeriveWsError::MissingCredentials { .. } => true,
118        DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
119        _ => false,
120    }
121}
122
123/// Classifies an HTTP status code.
124#[must_use]
125fn is_retryable_status(status: u16) -> bool {
126    matches!(status, 408 | 429) || (500..600).contains(&status)
127}
128
129/// Classifies a JSON-RPC error code.
130///
131/// Derive does not publish a stable retry classification for its venue codes,
132/// so the policy is conservative: only generic transient categories retry
133/// (the JSON-RPC `Server error` range, plus internal error `-32603` which the
134/// venue uses for transient backend faults). Signed-action rejections such as
135/// `signed_max_fee_too_low` and `insufficient_margin` arrive as standard
136/// invalid-params errors and are intentionally not retried; the caller has to
137/// reprice or refund collateral before resubmission.
138#[must_use]
139pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
140    code == -32603 || (-32099..=-32000).contains(&code)
141}
142
143/// Returns `true` only for JSON-RPC codes where the *outcome of a state-changing
144/// write* is genuinely ambiguous: the venue may have processed the request and
145/// merely failed to respond. Strictly narrower than [`is_retryable_jsonrpc_code`].
146///
147/// The retry classifier covers transient transport-style failures, including
148/// venue-defined codes like `-32000 Rate limit exceeded`. Rate-limit (and most
149/// other Derive server errors) is a **definitive** rejection: the gateway threw
150/// the request out before the matching engine saw it. Treating those as
151/// ambiguous leaves the order hanging in `Submitted` forever because no WS
152/// frame will come for an order that was never placed.
153///
154/// The current entry is `-32603` (generic JSON-RPC internal error): the only
155/// code where the venue's own process is known to have run for some unknown
156/// distance before failing. Extend this list only with evidence that a code
157/// genuinely leaves outcome unknown.
158#[must_use]
159pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
160    code == -32603
161}
162
163/// Returns `true` for non-JSON-RPC HTTP statuses where a state-changing
164/// write failed before the matching engine could accept it.
165///
166/// HTTP 4xx responses come from gateway, auth, throttling, or request-shape
167/// rejection paths. They are definitive for submit/cancel/modify outcomes,
168/// even when an idempotent read would retry some of them. HTTP 5xx and
169/// transport failures remain ambiguous for writes.
170///
171/// Retained for the HTTP order-write path (the execution client now writes over
172/// the WebSocket and classifies outcomes via `is_write_outcome_ambiguous_ws`).
173#[must_use]
174pub fn is_write_outcome_definitive_http_status(status: u16) -> bool {
175    (400..500).contains(&status)
176}
177
178/// Returns `true` when a WebSocket write's outcome is unknown (sent, but no
179/// clear venue verdict), so the caller emits no terminal event and lets
180/// reconciliation settle the order. `JsonRpc` defers to the shared code policy
181/// in [`is_write_outcome_ambiguous_jsonrpc`] (only `-32603`).
182///
183/// Two non-obvious calls: `Serde` is ambiguous because it is a failure to decode
184/// the *response* (the request cannot fail to serialize), so the action may have
185/// been processed; `NotConnected` is definitive because it is returned before
186/// the frame is sent, so the order was never placed.
187#[must_use]
188pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
189    match error {
190        DeriveWsError::Transport(_)
191        | DeriveWsError::RequestCancelled { .. }
192        | DeriveWsError::Timeout { .. }
193        | DeriveWsError::Serde(_) => true,
194        DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
195        DeriveWsError::NotConnected
196        | DeriveWsError::Auth(_)
197        | DeriveWsError::Authentication { .. }
198        | DeriveWsError::Subscription { .. }
199        | DeriveWsError::MissingCredentials { .. } => false,
200    }
201}
202
203/// Classifies a JSON-RPC error code as fatal. Derive currently does not
204/// expose a dedicated session-killed code, so this only flags the standard
205/// invalid-request shape used for unrecoverable framing problems.
206#[must_use]
207fn is_fatal_jsonrpc_code(code: i64) -> bool {
208    matches!(code, -32600 | -32700)
209}
210
211#[cfg(test)]
212mod tests {
213    use rstest::rstest;
214    use serde_json::Value;
215
216    use super::*;
217
218    #[rstest]
219    fn test_transport_error_retryable() {
220        let err = DeriveHttpError::transport("conn reset");
221        assert!(should_retry_http_error(&err));
222        assert!(!is_fatal_http_error(&err));
223    }
224
225    #[rstest]
226    #[case(500, true)]
227    #[case(502, true)]
228    #[case(503, true)]
229    #[case(504, true)]
230    #[case(429, true)]
231    #[case(408, true)]
232    #[case(400, false)]
233    #[case(404, false)]
234    #[case(409, false)]
235    #[case(422, false)]
236    fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
237        let err = DeriveHttpError::http(status, "body");
238        assert_eq!(should_retry_http_error(&err), retryable);
239    }
240
241    #[rstest]
242    #[case(401)]
243    #[case(403)]
244    fn test_http_auth_status_is_fatal(#[case] status: u16) {
245        let err = DeriveHttpError::http(status, "Unauthorized");
246        assert!(is_fatal_http_error(&err));
247        assert!(!should_retry_http_error(&err));
248    }
249
250    #[rstest]
251    fn test_jsonrpc_invalid_params_not_retryable() {
252        // Venue surfaces `signed_max_fee_too_low`, `insufficient_margin`, etc.
253        // as standard JSON-RPC -32602 invalid-params payloads. These reflect
254        // caller-side state and must never be retried.
255        let err = DeriveHttpError::JsonRpc {
256            code: -32602,
257            message: "signed_max_fee_too_low".into(),
258            data: None,
259        };
260        assert!(!should_retry_http_error(&err));
261        assert!(!is_fatal_http_error(&err));
262    }
263
264    #[rstest]
265    fn test_jsonrpc_server_error_range_retryable() {
266        let err = DeriveHttpError::JsonRpc {
267            code: -32050,
268            message: "Server busy".into(),
269            data: None,
270        };
271        assert!(should_retry_http_error(&err));
272    }
273
274    #[rstest]
275    fn test_jsonrpc_internal_error_retryable() {
276        let err = DeriveHttpError::JsonRpc {
277            code: -32603,
278            message: "Internal error".into(),
279            data: None,
280        };
281        assert!(should_retry_http_error(&err));
282    }
283
284    #[rstest]
285    #[case(400, true)]
286    #[case(401, true)]
287    #[case(403, true)]
288    #[case(408, true)]
289    #[case(429, true)]
290    #[case(500, false)]
291    #[case(503, false)]
292    fn test_http_status_write_outcome_classification(
293        #[case] status: u16,
294        #[case] definitive: bool,
295    ) {
296        assert_eq!(is_write_outcome_definitive_http_status(status), definitive);
297    }
298
299    #[rstest]
300    fn test_jsonrpc_invalid_request_is_fatal() {
301        let err = DeriveHttpError::JsonRpc {
302            code: -32600,
303            message: "Invalid request".into(),
304            data: Some(Value::Null),
305        };
306        assert!(is_fatal_http_error(&err));
307        assert!(!should_retry_http_error(&err));
308    }
309
310    #[rstest]
311    fn test_missing_credentials_terminal() {
312        let err = DeriveHttpError::MissingCredentials {
313            method: "private/order".into(),
314        };
315        assert!(!should_retry_http_error(&err));
316        assert!(is_fatal_http_error(&err));
317    }
318
319    #[rstest]
320    fn test_ws_transport_retryable() {
321        let err = DeriveWsError::transport("send failed");
322        assert!(should_retry_ws_error(&err));
323    }
324
325    #[rstest]
326    fn test_ws_not_connected_terminal() {
327        let err = DeriveWsError::NotConnected;
328        assert!(!should_retry_ws_error(&err));
329        assert!(!is_fatal_ws_error(&err));
330    }
331
332    #[rstest]
333    fn test_ws_request_cancelled_retryable() {
334        // The handler drops the oneshot on reconnect; the caller can re-issue
335        // after the new session is up.
336        let err = DeriveWsError::RequestCancelled {
337            method: "subscribe".into(),
338        };
339        assert!(should_retry_ws_error(&err));
340    }
341
342    #[rstest]
343    fn test_ws_timeout_retryable_not_fatal() {
344        let err = DeriveWsError::Timeout {
345            method: "private/order".into(),
346        };
347        assert!(should_retry_ws_error(&err));
348        assert!(!is_fatal_ws_error(&err));
349    }
350
351    #[rstest]
352    fn test_ws_authentication_and_subscription_failures_are_terminal() {
353        let authentication = DeriveWsError::Authentication {
354            operation: "private/order".into(),
355            reason: "session recovery failed".into(),
356        };
357        let subscription = DeriveWsError::Subscription {
358            details: "30769.trades: unauthorized".into(),
359        };
360
361        assert!(!should_retry_ws_error(&authentication));
362        assert!(is_fatal_ws_error(&authentication));
363        assert!(!should_retry_ws_error(&subscription));
364        assert!(!is_fatal_ws_error(&subscription));
365    }
366
367    #[rstest]
368    fn test_ws_write_outcome_ambiguous_classification() {
369        // Sent-but-unconfirmed outcomes are ambiguous; everything else is a
370        // definitive rejection the caller can surface as a terminal event.
371        let ambiguous = [
372            DeriveWsError::transport("send failed"),
373            DeriveWsError::RequestCancelled {
374                method: "private/order".into(),
375            },
376            DeriveWsError::Timeout {
377                method: "private/order".into(),
378            },
379            // A response the client cannot decode: the action may have been
380            // processed, so await reconciliation rather than reject.
381            DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
382            DeriveWsError::JsonRpc {
383                code: -32603,
384                message: "Internal error".into(),
385                data: None,
386            },
387        ];
388        let definitive = [
389            DeriveWsError::NotConnected,
390            DeriveWsError::JsonRpc {
391                code: -32602,
392                message: "signed_max_fee_too_low".into(),
393                data: None,
394            },
395            DeriveWsError::MissingCredentials {
396                operation: "private/order".into(),
397            },
398            DeriveWsError::Authentication {
399                operation: "private/order".into(),
400                reason: "session recovery failed".into(),
401            },
402            DeriveWsError::Subscription {
403                details: "30769.trades: unauthorized".into(),
404            },
405        ];
406
407        for err in &ambiguous {
408            assert!(
409                is_write_outcome_ambiguous_ws(err),
410                "expected ambiguous: {err}"
411            );
412        }
413
414        for err in &definitive {
415            assert!(
416                !is_write_outcome_ambiguous_ws(err),
417                "expected definitive: {err}",
418            );
419        }
420    }
421}