Skip to main content

deribit_mcp/
error.rs

1//! Adapter error type that crosses the MCP boundary.
2//!
3//! `AdapterError` is the only error type the MCP layer ever returns to a
4//! client. Upstream errors from `deribit_http`, `deribit_websocket`, and
5//! `serde_json` are mapped into one of the structured variants below via
6//! `From` impls — the MCP wire never sees raw upstream types.
7//!
8//! Variants are **structured**, not opaque strings. The serde-tagged
9//! representation (`{"kind": "...", ...}`) is what callers parse off the
10//! wire when a tool call returns `isError: true`.
11
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use deribit_http::HttpError;
18use deribit_websocket::error::WebSocketError;
19
20/// Default retry hint when an upstream rate-limit signal does not carry
21/// a server-supplied value (e.g. `HttpError::RateLimitExceeded` with no
22/// `Retry-After` header).
23const DEFAULT_RATE_LIMIT_RETRY_MS: u64 = 1_000;
24
25/// Errors emitted by the adapter and surfaced over the MCP wire.
26///
27/// Every fallible path in the adapter ultimately produces one of these
28/// variants. The serde representation uses an internally tagged
29/// `"kind"` discriminant (`#[serde(tag = "kind")]`) so the JSON shape
30/// is stable and discoverable by an LLM client.
31///
32/// Variants are intentionally **closed** — `_` arms on `AdapterError`
33/// are forbidden by the project's coding rules. Add a new variant
34/// when a structurally new error kind appears.
35#[derive(Debug, Error, Serialize, Deserialize, PartialEq)]
36#[serde(tag = "kind")]
37pub enum AdapterError {
38    /// Authentication / authorization failure.
39    #[error("authentication failed: {reason:?}")]
40    Auth {
41        /// Why authentication failed.
42        reason: AuthFailureReason,
43    },
44
45    /// Rate-limit signal from upstream. The MCP layer never silently
46    /// retries (ADR-0008); it surfaces the hint so the LLM can decide.
47    #[error("rate limited; retry after {retry_after_ms} ms")]
48    RateLimited {
49        /// Suggested wait before re-issuing the call. Zero means
50        /// "unknown — back off and retry".
51        retry_after_ms: u64,
52    },
53
54    /// Upstream returned a structured error that is neither auth nor
55    /// rate-limit shaped.
56    #[error("upstream error: {inner:?}")]
57    Upstream {
58        /// Where the error came from and the structured payload.
59        #[serde(rename = "source")]
60        inner: UpstreamErrorKind,
61    },
62
63    /// Input failed validation at the MCP boundary before any upstream
64    /// call was made.
65    #[error("validation failed for `{field}`: {message}")]
66    Validation {
67        /// Field name as it appears in the tool's input schema.
68        field: String,
69        /// Human-readable explanation; safe to surface to an LLM.
70        message: String,
71    },
72
73    /// `--max-order-usd` cap exceeded. Trading-class concern; surfaced
74    /// here so all errors flow through one type.
75    #[error("requested {requested} USD exceeds cap {cap} USD")]
76    SizeCapExceeded {
77        /// Requested notional in USD.
78        requested: f64,
79        /// Configured cap in USD.
80        cap: f64,
81    },
82
83    /// A tool exists but is not enabled in this binary's configuration
84    /// (e.g. `Trading` without `--allow-trading`).
85    #[error("tool `{tool}` requires `{flag}`")]
86    NotEnabled {
87        /// Tool name as listed in the registry. Construct via
88        /// [`AdapterError::not_enabled`] from a `&'static str` literal.
89        tool: String,
90        /// CLI flag that would enable it.
91        flag: String,
92    },
93
94    /// Last-resort variant for failures that should not propagate
95    /// detail across the MCP boundary (e.g. an upstream payload that
96    /// might leak a signature). The original error is logged at DEBUG
97    /// with the redaction filter active.
98    #[error("internal error: {reason}")]
99    Internal {
100        /// Pre-vetted reason string. Construct via
101        /// [`AdapterError::internal`] from a `&'static str` literal —
102        /// never user-controlled content.
103        reason: String,
104    },
105}
106
107/// Why authentication failed. Closed set — exhaustive matches required.
108///
109/// Drops [`Copy`] (was present in v0.1) because
110/// [`Self::ScopeInsufficient`] now carries the missing scope name as
111/// a `String` payload so the LLM client can surface it.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "kind", rename_all = "snake_case")]
114pub enum AuthFailureReason {
115    /// Credentials missing — neither `client_id` nor `client_secret`
116    /// were configured. Surfaces when an `Account` / `Trading` tool
117    /// is called against an anonymous adapter.
118    MissingCredentials,
119    /// Credentials present but rejected by Deribit (HTTP `401` or
120    /// upstream JSON-RPC code `10004`).
121    Unauthorized,
122    /// A previously valid token expired and the upstream refresh
123    /// flow could not obtain a replacement (network error during
124    /// refresh, refresh token revoked, OAuth provider timeout, …).
125    TokenExpiredAndRefreshFailed,
126    /// Account suspended on Deribit's side (e.g. KYC failure,
127    /// regulatory hold). Distinct from `Unauthorized` so the LLM
128    /// can advise the user to contact support rather than retry.
129    Suspended,
130    /// The configured credentials authenticated successfully but the
131    /// requested operation needs a scope that was not granted (e.g.
132    /// a `Trading` tool call without `trade:read_write`). The
133    /// payload names the scope the LLM should ask the operator to
134    /// add to the API key.
135    ScopeInsufficient {
136        /// Scope name as documented by Deribit (`trade:read_write`,
137        /// `account:read`, `wallet:read_write`, …).
138        needed: String,
139    },
140}
141
142/// Structured upstream-error payload. Closed set.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(tag = "transport", rename_all = "snake_case")]
145pub enum UpstreamErrorKind {
146    /// Deribit JSON-RPC API error: a non-rate-limit, non-auth failure
147    /// returned with a `code` + `message` body.
148    Api {
149        /// Deribit error code, when available.
150        code: Option<i64>,
151        /// Human-readable message after secret redaction.
152        message: String,
153    },
154    /// Network-layer error (connect, TLS, DNS, …).
155    Network {
156        /// Short description; safe for the wire.
157        message: String,
158    },
159    /// HTTP transport error that doesn't fit the structured shapes
160    /// above (e.g. invalid response, parse).
161    Http {
162        /// Short description; safe for the wire.
163        message: String,
164    },
165    /// WebSocket transport error.
166    Websocket {
167        /// Short description; safe for the wire.
168        message: String,
169    },
170    /// FIX 4.4 transport error (v0.6+).
171    ///
172    /// Surfaces FIX-side failures classified by [`FixErrorKind`]. The
173    /// adapter maps every `deribit_fix::DeribitFixError` variant into
174    /// one of those structured kinds via an exhaustive match —
175    /// adding a new upstream variant fails to compile until the
176    /// mapping is revisited.
177    #[cfg(feature = "fix")]
178    Fix {
179        /// Closed-set classification of the FIX failure.
180        kind: FixErrorKind,
181        /// Short description; safe for the wire.
182        message: String,
183    },
184}
185
186/// FIX-side failure classification surfaced via
187/// [`UpstreamErrorKind::Fix`].
188#[cfg(feature = "fix")]
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum FixErrorKind {
192    /// FIX session was disconnected (TCP drop, peer logout, etc).
193    Disconnected,
194    /// Logon was refused by the peer with a structured FIX
195    /// `BusinessMessageReject (j)` or `Reject (3)` reason. The
196    /// adapter surfaces this as a `SessionReject` so the LLM can
197    /// distinguish protocol-level rejection from a generic
198    /// disconnect.
199    SessionReject,
200    /// The FIX configuration was rejected at construction time
201    /// (e.g. missing username / endpoint).
202    Config,
203    /// Catch-all for protocol / parsing / I/O / timeout failures
204    /// that don't fit the categories above. Carries the original
205    /// upstream message in the parent payload's `message` field.
206    Other,
207}
208
209impl AdapterError {
210    /// Convenience constructor for [`AdapterError::Validation`].
211    #[cold]
212    #[inline(never)]
213    #[must_use]
214    pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
215        Self::Validation {
216            field: field.into(),
217            message: message.into(),
218        }
219    }
220
221    /// Convenience constructor for [`AdapterError::RateLimited`] from a
222    /// [`Duration`].
223    #[cold]
224    #[inline(never)]
225    #[must_use]
226    pub fn rate_limited(retry_after: Duration) -> Self {
227        let retry_after_ms = u64::try_from(retry_after.as_millis()).unwrap_or(u64::MAX);
228        Self::RateLimited { retry_after_ms }
229    }
230}
231
232impl From<HttpError> for AdapterError {
233    fn from(err: HttpError) -> Self {
234        match err {
235            HttpError::AuthenticationFailed(message) => AdapterError::Auth {
236                reason: classify_auth_failure_reason(&message),
237            },
238            HttpError::RateLimitExceeded => AdapterError::RateLimited {
239                retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
240            },
241            HttpError::NetworkError(message) => AdapterError::Upstream {
242                inner: UpstreamErrorKind::Network { message },
243            },
244            HttpError::RequestFailed(message)
245            | HttpError::InvalidResponse(message)
246            | HttpError::ParseError(message) => {
247                // The upstream `deribit-http` client surfaces Deribit
248                // API error responses through `RequestFailed` carrying a
249                // `"API error: <code> - <text>"` body (sometimes
250                // prefixed with the operation name, e.g. `"Buy order
251                // failed: API error: 11044 - …"`). Extract the
252                // structured code so the LLM sees a typed
253                // `UpstreamErrorKind::Api { code, message }` instead of
254                // an opaque `Http { message }`.
255                if let Some((code, msg)) = parse_api_error(&message) {
256                    return AdapterError::Upstream {
257                        inner: UpstreamErrorKind::Api {
258                            code: Some(code),
259                            message: msg,
260                        },
261                    };
262                }
263                AdapterError::Upstream {
264                    inner: UpstreamErrorKind::Http { message },
265                }
266            }
267            HttpError::ConfigError(_) => {
268                AdapterError::internal("upstream HTTP client misconfigured")
269            }
270        }
271    }
272}
273
274#[cfg(feature = "fix")]
275impl From<deribit_fix::error::DeribitFixError> for AdapterError {
276    fn from(err: deribit_fix::error::DeribitFixError) -> Self {
277        use deribit_fix::error::DeribitFixError as Fx;
278        // Exhaustive match on the upstream enum: adding a new
279        // upstream variant fails to compile here, forcing a
280        // reviewer to decide on its mapping.
281        match err {
282            Fx::Authentication(message) => AdapterError::Auth {
283                reason: classify_auth_failure_reason(&message),
284            },
285            Fx::Connection(message) => AdapterError::Upstream {
286                inner: UpstreamErrorKind::Fix {
287                    kind: FixErrorKind::Disconnected,
288                    message,
289                },
290            },
291            Fx::Io(io) => AdapterError::Upstream {
292                inner: UpstreamErrorKind::Fix {
293                    kind: FixErrorKind::Disconnected,
294                    message: io.to_string(),
295                },
296            },
297            Fx::Session(message)
298            | Fx::Protocol(message)
299            | Fx::MessageParsing(message)
300            | Fx::MessageConstruction(message) => AdapterError::Upstream {
301                inner: UpstreamErrorKind::Fix {
302                    kind: FixErrorKind::SessionReject,
303                    message,
304                },
305            },
306            Fx::Config(message) => AdapterError::Upstream {
307                inner: UpstreamErrorKind::Fix {
308                    kind: FixErrorKind::Config,
309                    message,
310                },
311            },
312            Fx::Timeout(message) | Fx::Generic(message) => AdapterError::Upstream {
313                inner: UpstreamErrorKind::Fix {
314                    kind: FixErrorKind::Other,
315                    message,
316                },
317            },
318            Fx::Json(json) => AdapterError::Upstream {
319                inner: UpstreamErrorKind::Fix {
320                    kind: FixErrorKind::Other,
321                    message: json.to_string(),
322                },
323            },
324            Fx::Http(http) => AdapterError::Upstream {
325                inner: UpstreamErrorKind::Fix {
326                    kind: FixErrorKind::Other,
327                    message: http.to_string(),
328                },
329            },
330        }
331    }
332}
333
334#[cfg(test)]
335#[cfg(feature = "fix")]
336mod fix_wire_tests {
337    use super::*;
338
339    /// Pin the JSON wire shape of every documented [`FixErrorKind`]
340    /// variant so future renames force a deliberate review.
341    #[test]
342    fn fix_upstream_round_trips_through_serde() {
343        for (kind, expected_kind) in [
344            (FixErrorKind::Disconnected, "disconnected"),
345            (FixErrorKind::SessionReject, "session_reject"),
346            (FixErrorKind::Config, "config"),
347            (FixErrorKind::Other, "other"),
348        ] {
349            let err = AdapterError::Upstream {
350                inner: UpstreamErrorKind::Fix {
351                    kind,
352                    message: "boom".to_string(),
353                },
354            };
355            let value = serde_json::to_value(&err).expect("ser");
356            // The `Upstream` variant carries its payload under
357            // `source` because the field is `#[serde(rename = "source")]`
358            // — see `AdapterError::Upstream`.
359            assert_eq!(value["kind"], "Upstream", "outer tag");
360            assert_eq!(value["source"]["transport"], "fix");
361            assert_eq!(value["source"]["kind"], expected_kind);
362            assert_eq!(value["source"]["message"], "boom");
363            let round_trip: AdapterError = serde_json::from_value(value).expect("de");
364            assert_eq!(round_trip, err);
365        }
366    }
367}
368
369/// Parse an `"API error: <code> - <message>"` substring out of a
370/// `deribit-http` `RequestFailed` body. Returns `None` when the
371/// pattern is not found or the code does not parse as `i64`
372/// (matching the `UpstreamErrorKind::Api { code: Option<i64>, … }`
373/// wire shape). Used at the `HttpError → AdapterError` boundary to
374/// route Deribit API errors into the structured
375/// `UpstreamErrorKind::Api` shape.
376#[cold]
377#[inline(never)]
378fn parse_api_error(message: &str) -> Option<(i64, String)> {
379    let after = message.split_once("API error:")?.1.trim_start();
380    let (code_str, rest) = after.split_once(" - ").or_else(|| after.split_once('-'))?;
381    let code: i64 = code_str.trim().parse().ok()?;
382    Some((code, rest.trim().to_string()))
383}
384
385/// Classify a free-text upstream auth-failure message into the
386/// closed-set [`AuthFailureReason`] surface. Pattern-matches on the
387/// Deribit-documented codes / phrases without leaking the raw body.
388///
389/// Mapping (per `doc/DERIBIT-INTEGRATION.md` §3.3):
390///
391/// - `10005` / `account is suspended` → [`AuthFailureReason::Suspended`].
392/// - `13009` / `unauthorized scope` / `scope insufficient` /
393///   `insufficient scope` / `scope required` →
394///   [`AuthFailureReason::ScopeInsufficient`]. Matches require either
395///   the documented numeric code or one of those exact phrases — a
396///   bare mention of the word "scope" is **not** enough, to avoid
397///   misclassifying generic OAuth wording as a scope error.
398/// - `13004` / `invalid_token` / `token expired` →
399///   [`AuthFailureReason::TokenExpiredAndRefreshFailed`].
400/// - everything else (including `10004`, `401`, anything explicitly
401///   marked unauthorized) → [`AuthFailureReason::Unauthorized`].
402#[cold]
403#[inline(never)]
404fn classify_auth_failure_reason(message: &str) -> AuthFailureReason {
405    let lower = message.to_ascii_lowercase();
406
407    if lower.contains("10005") || lower.contains("suspend") {
408        return AuthFailureReason::Suspended;
409    }
410    if is_scope_insufficient(&lower) {
411        // Only mint a `ScopeInsufficient` when we can name the scope;
412        // the LLM client expects an actionable `needed:` payload.
413        // Fall back to `Unauthorized` when the upstream phrase
414        // matches but the scope name is not embedded — better to
415        // surface the broader category than fabricate a value.
416        if let Some(needed) = extract_scope(&lower) {
417            return AuthFailureReason::ScopeInsufficient { needed };
418        }
419    }
420    if lower.contains("13004")
421        || lower.contains("invalid_token")
422        || lower.contains("token expired")
423        || lower.contains("token has expired")
424    {
425        return AuthFailureReason::TokenExpiredAndRefreshFailed;
426    }
427
428    AuthFailureReason::Unauthorized
429}
430
431/// Whether `lower` (already lowercased) carries one of the
432/// documented scope-insufficient signals.
433fn is_scope_insufficient(lower: &str) -> bool {
434    if lower.contains("13009") {
435        return true;
436    }
437    const PHRASES: &[&str] = &[
438        "scope insufficient",
439        "insufficient scope",
440        "unauthorized scope",
441        "scope required",
442        "missing scope",
443    ];
444    PHRASES.iter().any(|p| lower.contains(p))
445}
446
447/// Pull the scope name out of an upstream "scope insufficient"
448/// message of shape `... scope <name> ...` or `... needs <name> ...`.
449/// Returns `None` when the message format does not embed a scope.
450fn extract_scope(lower: &str) -> Option<String> {
451    for marker in ["scope ", "needs ", "requires "] {
452        if let Some(idx) = lower.find(marker) {
453            let rest = &lower[idx + marker.len()..];
454            let token = rest
455                .split(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ',' || c == '.')
456                .find(|s| !s.is_empty())?;
457            return Some(token.to_string());
458        }
459    }
460    None
461}
462
463impl From<WebSocketError> for AdapterError {
464    fn from(err: WebSocketError) -> Self {
465        match err {
466            WebSocketError::AuthenticationFailed(_) => AdapterError::Auth {
467                reason: AuthFailureReason::Unauthorized,
468            },
469            WebSocketError::ApiError { code, message, .. } => match code {
470                10009 | 10028 | 10040 | 10041 => AdapterError::RateLimited {
471                    retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
472                },
473                10000 | 10001 | 10002 | 13004 | 13005 | 13007 | 13008 | 13009 => {
474                    AdapterError::Auth {
475                        reason: AuthFailureReason::Unauthorized,
476                    }
477                }
478                _ => AdapterError::Upstream {
479                    inner: UpstreamErrorKind::Api {
480                        code: Some(code),
481                        message,
482                    },
483                },
484            },
485            // Every other WS variant flows through the generic
486            // `Websocket` upstream payload so the LLM still sees a
487            // structured error rather than `Internal`. The string is
488            // truncated to keep the wire payload bounded.
489            other => AdapterError::Upstream {
490                inner: UpstreamErrorKind::Websocket {
491                    message: ws_short(&other.to_string()),
492                },
493            },
494        }
495    }
496}
497
498/// Truncate a one-line WS error string to a stable, bounded wire size.
499#[inline]
500fn ws_short(s: &str) -> String {
501    const MAX: usize = 256;
502    if s.len() <= MAX {
503        s.to_string()
504    } else {
505        let mut end = MAX;
506        while !s.is_char_boundary(end) {
507            end -= 1;
508        }
509        let mut out = String::with_capacity(end + 1);
510        out.push_str(&s[..end]);
511        out.push('…');
512        out
513    }
514}
515
516impl From<serde_json::Error> for AdapterError {
517    fn from(_err: serde_json::Error) -> Self {
518        AdapterError::internal("upstream payload schema mismatch")
519    }
520}
521
522impl AdapterError {
523    /// Convenience constructor for [`AdapterError::NotEnabled`].
524    #[cold]
525    #[inline(never)]
526    #[must_use]
527    pub fn not_enabled(tool: &'static str, flag: &'static str) -> Self {
528        Self::NotEnabled {
529            tool: tool.to_string(),
530            flag: flag.to_string(),
531        }
532    }
533
534    /// Convenience constructor for [`AdapterError::Internal`].
535    #[cold]
536    #[inline(never)]
537    #[must_use]
538    pub fn internal(reason: &'static str) -> Self {
539        Self::Internal {
540            reason: reason.to_string(),
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    fn round_trip(err: &AdapterError) -> AdapterError {
550        let json = serde_json::to_string(err).expect("serialize");
551        serde_json::from_str(&json).expect("deserialize")
552    }
553
554    #[test]
555    fn auth_round_trip() {
556        for reason in [
557            AuthFailureReason::MissingCredentials,
558            AuthFailureReason::Unauthorized,
559            AuthFailureReason::TokenExpiredAndRefreshFailed,
560            AuthFailureReason::Suspended,
561            AuthFailureReason::ScopeInsufficient {
562                needed: "trade:read_write".to_string(),
563            },
564        ] {
565            let err = AdapterError::Auth { reason };
566            assert_eq!(err, round_trip(&err));
567        }
568    }
569
570    #[test]
571    fn http_authentication_failed_classifies_suspended() {
572        let err: AdapterError =
573            HttpError::AuthenticationFailed("api error 10005: account suspended".into()).into();
574        assert_eq!(
575            err,
576            AdapterError::Auth {
577                reason: AuthFailureReason::Suspended
578            }
579        );
580    }
581
582    #[test]
583    fn http_authentication_failed_classifies_scope_insufficient() {
584        let err: AdapterError =
585            HttpError::AuthenticationFailed("api error 13009: scope trade:read_write".into())
586                .into();
587        assert_eq!(
588            err,
589            AdapterError::Auth {
590                reason: AuthFailureReason::ScopeInsufficient {
591                    needed: "trade:read_write".to_string(),
592                }
593            }
594        );
595    }
596
597    #[test]
598    fn auth_failure_with_word_scope_in_unrelated_phrase_is_unauthorized() {
599        // The upstream sometimes embeds "scope" in OAuth wording
600        // unrelated to scope-insufficient (e.g. "out of scope of
601        // current session"). Make sure that does NOT misclassify
602        // as `ScopeInsufficient`.
603        let err: AdapterError =
604            HttpError::AuthenticationFailed("error 10004: out of scope of current session".into())
605                .into();
606        assert_eq!(
607            err,
608            AdapterError::Auth {
609                reason: AuthFailureReason::Unauthorized
610            }
611        );
612    }
613
614    #[test]
615    fn auth_failure_with_13009_but_no_scope_name_falls_back_to_unauthorized() {
616        // The 13009 marker is documented as scope-insufficient, but
617        // when the message does not embed an actionable scope name
618        // we surface the broader `Unauthorized` rather than mint a
619        // fabricated `needed:` payload.
620        let err: AdapterError =
621            HttpError::AuthenticationFailed("api error 13009: unspecified".into()).into();
622        assert_eq!(
623            err,
624            AdapterError::Auth {
625                reason: AuthFailureReason::Unauthorized
626            }
627        );
628    }
629
630    #[test]
631    fn http_authentication_failed_classifies_token_expired() {
632        let err: AdapterError =
633            HttpError::AuthenticationFailed("api error 13004: invalid_token".into()).into();
634        assert_eq!(
635            err,
636            AdapterError::Auth {
637                reason: AuthFailureReason::TokenExpiredAndRefreshFailed,
638            }
639        );
640    }
641
642    #[test]
643    fn rate_limited_round_trip() {
644        let err = AdapterError::RateLimited {
645            retry_after_ms: 2_000,
646        };
647        assert_eq!(err, round_trip(&err));
648    }
649
650    #[test]
651    fn upstream_api_round_trip() {
652        let err = AdapterError::Upstream {
653            inner: UpstreamErrorKind::Api {
654                code: Some(10000),
655                message: "boom".to_string(),
656            },
657        };
658        assert_eq!(err, round_trip(&err));
659    }
660
661    #[test]
662    fn upstream_network_round_trip() {
663        let err = AdapterError::Upstream {
664            inner: UpstreamErrorKind::Network {
665                message: "dns".to_string(),
666            },
667        };
668        assert_eq!(err, round_trip(&err));
669    }
670
671    #[test]
672    fn upstream_websocket_round_trip() {
673        let err = AdapterError::Upstream {
674            inner: UpstreamErrorKind::Websocket {
675                message: "closed".to_string(),
676            },
677        };
678        assert_eq!(err, round_trip(&err));
679    }
680
681    #[test]
682    fn validation_round_trip() {
683        let err = AdapterError::validation("instrument_name", "must be non-empty");
684        assert_eq!(err, round_trip(&err));
685    }
686
687    #[test]
688    fn size_cap_exceeded_round_trip() {
689        let err = AdapterError::SizeCapExceeded {
690            requested: 25_000.0,
691            cap: 10_000.0,
692        };
693        assert_eq!(err, round_trip(&err));
694    }
695
696    #[test]
697    fn not_enabled_round_trip() {
698        let err = AdapterError::not_enabled("place_order", "--allow-trading");
699        assert_eq!(err, round_trip(&err));
700    }
701
702    #[test]
703    fn internal_round_trip() {
704        let err = AdapterError::internal("upstream payload schema mismatch");
705        assert_eq!(err, round_trip(&err));
706    }
707
708    #[test]
709    fn http_authentication_failed_maps_to_auth_unauthorized() {
710        // Anything that does not match a more specific marker falls
711        // back to `Unauthorized` — the v0.1 default.
712        let err: AdapterError = HttpError::AuthenticationFailed("bad creds".into()).into();
713        assert_eq!(
714            err,
715            AdapterError::Auth {
716                reason: AuthFailureReason::Unauthorized
717            }
718        );
719    }
720
721    #[test]
722    fn http_rate_limit_exceeded_maps_to_rate_limited() {
723        let err: AdapterError = HttpError::RateLimitExceeded.into();
724        assert!(matches!(err, AdapterError::RateLimited { .. }));
725    }
726
727    #[test]
728    fn http_network_error_maps_to_upstream_network() {
729        let err: AdapterError = HttpError::NetworkError("connect".into()).into();
730        assert!(matches!(
731            err,
732            AdapterError::Upstream {
733                inner: UpstreamErrorKind::Network { .. }
734            }
735        ));
736    }
737
738    #[test]
739    fn http_request_failed_maps_to_upstream_http() {
740        let err: AdapterError = HttpError::RequestFailed("500".into()).into();
741        assert!(matches!(
742            err,
743            AdapterError::Upstream {
744                inner: UpstreamErrorKind::Http { .. }
745            }
746        ));
747    }
748
749    #[test]
750    fn http_config_error_maps_to_internal() {
751        let err: AdapterError = HttpError::ConfigError("bad url".into()).into();
752        assert!(matches!(err, AdapterError::Internal { .. }));
753    }
754
755    #[test]
756    fn ws_authentication_failed_maps_to_auth_unauthorized() {
757        let err: AdapterError = WebSocketError::AuthenticationFailed("bad".into()).into();
758        assert_eq!(
759            err,
760            AdapterError::Auth {
761                reason: AuthFailureReason::Unauthorized
762            }
763        );
764    }
765
766    #[test]
767    fn ws_api_error_rate_limit_code_maps_to_rate_limited() {
768        let err: AdapterError = WebSocketError::ApiError {
769            code: 10028,
770            message: "too many".into(),
771            method: None,
772            params: None,
773            raw_response: None,
774        }
775        .into();
776        assert!(matches!(err, AdapterError::RateLimited { .. }));
777    }
778
779    #[test]
780    fn ws_api_error_other_code_maps_to_upstream_api() {
781        let err: AdapterError = WebSocketError::ApiError {
782            code: 11099,
783            message: "boom".into(),
784            method: None,
785            params: None,
786            raw_response: None,
787        }
788        .into();
789        match err {
790            AdapterError::Upstream {
791                inner: UpstreamErrorKind::Api { code, .. },
792            } => {
793                assert_eq!(code, Some(11099));
794            }
795            other => panic!("unexpected: {other:?}"),
796        }
797    }
798
799    #[test]
800    fn serde_json_error_maps_to_internal() {
801        let parse: Result<i32, _> = serde_json::from_str("not json");
802        let err: AdapterError = parse.unwrap_err().into();
803        assert!(matches!(err, AdapterError::Internal { .. }));
804    }
805}