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}