Skip to main content

degenbot_cli_core/
error.rs

1//! Typed console failures and the single [`CliError`] → [`ExitCode`] mapping
2//! (ADR-051 D1).
3//!
4//! The workspace lint `exit = "deny"` forbids a library from aborting the host
5//! process: `run` returns codes. `EX_CONFIG` (78, sysexits) is the typed fleet
6//! boot refusal lifted out of `DegenbotCLI.invoke` (FF-T1, BPHR6F).
7
8use std::fmt;
9
10use degenbot_aave::RunError as AaveRunError;
11use degenbot_config::ConfigError;
12use degenbot_db::DbError;
13use degenbot_pool_updater::RunError as PoolRunError;
14
15/// The process exit code a command run maps to.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExitCode {
18    /// `0`: the command completed (including a `--dry-run`, which writes nothing,
19    /// and a cooperative `Cancelled` run, whose committed chunks stay durable).
20    Success,
21    /// `1`: a typed command failure, including a declined confirmation (the click
22    /// `Abort` arm).
23    Failure,
24    /// `78` (sysexits `EX_CONFIG`): the typed fleet boot refusal — the host cannot
25    /// host the fleet configuration (FF-T1).
26    Config,
27}
28
29impl ExitCode {
30    /// The numeric process exit code.
31    #[must_use]
32    pub const fn code(self) -> i32 {
33        match self {
34            Self::Success => 0,
35            Self::Failure => 1,
36            Self::Config => 78,
37        }
38    }
39}
40
41/// One exchange's committed resume state, snapshotted read-only from the
42/// operator database's `exchanges.last_update_block` cursor column.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExchangeResumeState {
45    /// The `exchanges.name` slug.
46    pub name: String,
47    /// The committed cursor; `None` when the exchange was never updated.
48    pub last_update_block: Option<i64>,
49}
50
51/// A `pool update` (or `pool verify`) core failure plus the run context the
52/// arm held when the core returned it.
53///
54/// The bare library text (`api error: backend connection task has stopped`)
55/// alone is not an operator diagnostic, and the arm must not have to
56/// reconstruct the run's identity at the print site — so the payload carries
57/// it structurally: the endpoint, the chain, the requested block range, and,
58/// for a mid-run failure, the per-exchange resume state read out of the
59/// operator database. The updater commits per chunk, so a failed run keeps
60/// every committed chunk and leaves the remaining exchanges' outstanding
61/// work recorded in their cursors; the failure report makes it visible.
62#[derive(Debug)]
63pub struct PoolUpdateFailure {
64    /// The underlying core error.
65    pub error: PoolRunError,
66    /// The RPC endpoint the run was bound to.
67    pub rpc_url: String,
68    /// The chain the run advances.
69    pub chain_id: i64,
70    /// The first block the run intended to process (the earliest committed
71    /// cursor + 1; mirrors the core's own `initial_start_block`).
72    pub from_block: u64,
73    /// The requested upper bound; `None` when the run targeted the chain tip.
74    pub to_block: Option<u64>,
75    /// The post-failure per-exchange resume snapshot; `None` when the
76    /// read-only cursor read itself failed (or a non-run failure carried no
77    /// snapshot).
78    pub resume: Option<Vec<ExchangeResumeState>>,
79}
80
81impl PoolUpdateFailure {
82    /// The operator-facing failure text.
83    #[must_use]
84    pub fn message(&self) -> String {
85        let error_text = self.error.to_string();
86        let range = self.range_text();
87        let mut lines = if self.is_rpc_connection_failure(&error_text) {
88            vec![
89                format!(
90                    "Chain {}: the RPC connection to {} dropped mid-run while advancing blocks \
91                     {}; chunks already committed are kept. Rerunning resumes from the recorded \
92                     per-exchange cursors.",
93                    self.chain_id, self.rpc_url, range
94                ),
95                format!("  underlying error: {error_text}"),
96            ]
97        } else {
98            vec![format!(
99                "Chain {}: pool update against {} failed while advancing blocks {}: {error_text}",
100                self.chain_id, self.rpc_url, range
101            )]
102        };
103        lines.extend(self.resume_lines());
104        lines.join("\n")
105    }
106
107    /// The range half of the failure line: `A-B`, or `A onward` for a tip run.
108    fn range_text(&self) -> String {
109        match self.to_block {
110            Some(to) => format!("{}-{to}", self.from_block),
111            None => format!("{} onward (the chain tip)", self.from_block),
112        }
113    }
114
115    /// Whether the core error is the RPC-connection class — a dropped or
116    /// unreachable transport. The provider layer maps the transport's
117    /// `BackendGone` (and the rest of the connection class) onto the
118    /// `Connection failed` / `Request timeout` variants, which is what the
119    /// updater surfaces inside `PoolRunError::Provider`.
120    ///
121    /// The provider error's type is not nameable at this console layer (it
122    /// lives in `degenbot-core`, which the console does not depend on), so the
123    /// class is read off the stable `#[error]` prefixes that layer renders;
124    /// the variant boundary is still matched structurally.
125    fn is_rpc_connection_failure(&self, error_text: &str) -> bool {
126        matches!(&self.error, PoolRunError::Provider(_))
127            && (error_text.starts_with("rpc error: Connection failed: ")
128                || error_text.starts_with("rpc error: Request timeout: "))
129    }
130
131    /// The per-exchange resume lines: which exchanges are current, which are
132    /// behind, and which were never updated — the outstanding work a rerun
133    /// picks up from the recorded cursors.
134    fn resume_lines(&self) -> Vec<String> {
135        let Some(rows) = &self.resume else {
136            return vec![
137                "  exchange cursor state unavailable: the read-only resume check failed"
138                    .to_string(),
139            ];
140        };
141        if rows.is_empty() {
142            return vec![format!(
143                "  no active exchanges were registered for chain {}.",
144                self.chain_id
145            )];
146        }
147        let mut current = Vec::new();
148        let mut behind = Vec::new();
149        let mut never_updated = Vec::new();
150        for row in rows {
151            match (row.last_update_block, self.to_block) {
152                (None, _) => never_updated.push(row.name.clone()),
153                // A run targeting the chain tip leaves unprocessed work in
154                // front of every cursor, so none is current at the target.
155                (Some(block), None) => {
156                    behind.push(format!("{} (block {block})", row.name));
157                }
158                (Some(block), Some(to)) => {
159                    let to = i64::try_from(to).unwrap_or(i64::MAX);
160                    if block >= to {
161                        current.push(row.name.clone());
162                    } else {
163                        behind.push(format!("{} (block {block})", row.name));
164                    }
165                }
166            }
167        }
168        let mut lines = Vec::new();
169        if !current.is_empty() {
170            lines.push(format!(
171                "  current at the requested target: {}",
172                current.join(", ")
173            ));
174        }
175        if !behind.is_empty() {
176            lines.push(format!(
177                "  behind (last committed block): {}",
178                behind.join(", ")
179            ));
180        }
181        if !never_updated.is_empty() {
182            lines.push(format!("  never updated: {}", never_updated.join(", ")));
183        }
184        lines
185    }
186}
187
188/// A typed console failure.
189///
190/// Every variant carries the data the argv facade needs to render the
191/// operator-facing line through [`CliError::message`]; rendering itself is the
192/// facade's job (ADR-051 Q1).
193#[derive(Debug)]
194pub enum CliError {
195    /// The typed fleet boot refusal (FF-T1). Exits `EX_CONFIG` 78.
196    BootRefused(String),
197    /// The operator declined a confirmation prompt — the click `Abort` arm.
198    Aborted,
199    /// The `database upgrade` subcommand is retired: the database upgrades itself
200    /// at open (ADR-052), and `database heal` is the explicit repair.
201    DatabaseUpgradeRetired,
202    /// The file is a foreign `SQLite` database — the arm refuses to adopt it.
203    DatabaseForeign,
204    /// The schema state offers nothing for this arm (e.g. `cutover` on an empty
205    /// file with no legacy history).
206    DatabaseNothingToDo,
207    /// Any other database failure (I/O, integrity, heal verification).
208    Database(DbError),
209    /// A filesystem failure outside the database (e.g. `database reset`
210    /// bootstrapping a missing state-home directory chain).
211    Io(std::io::Error),
212    /// Driver-domain config resolution failed (ADR-051 D8).
213    Config(ConfigError),
214    /// An unknown chain selector (`--chain foo`): the console names chain slugs
215    /// (`base`, `ethereum`) or numeric chain ids.
216    UnknownChain {
217        /// The rejected selector, verbatim.
218        chain: String,
219    },
220    /// The deployments registry has no record for the resolved
221    /// `(chain_id, name)` pair.
222    UnknownDeployment {
223        /// The resolved chain id.
224        chain_id: u64,
225        /// The DEX name slug (the `--name` value).
226        name: String,
227    },
228    /// A malformed block identifier — the exact `Invalid block tag: {tag}`
229    /// refusal the Python `_resolve_to_block` raises.
230    InvalidBlockTag(String),
231    /// The RPC read that resolves a `tag:offset` block identifier failed.
232    BlockResolution(String),
233    /// The supplied address does not parse (the click `Abort` arm of
234    /// `aave position show`).
235    InvalidAddress(String),
236    /// A required command argument is missing or malformed (`--pool-manager`
237    /// on `--family v4`, an out-of-range chain id).
238    InvalidArgument(String),
239    /// `aave update` found no active Aave markets (the Python
240    /// `DegenbotValueError`).
241    NoActiveAaveMarkets,
242    /// A `pool update` / `pool verify` core failure (DB/RPC/cancelled/
243    /// verification), wrapped with the run context the arm held when the core
244    /// returned it (endpoint, chain, requested range, per-exchange resume
245    /// state) so the rendered diagnostic is complete.
246    ///
247    /// The payload is boxed to keep the variant out of the
248    /// `result_large_err` budget every arm's `Result` shares.
249    PoolUpdate(Box<PoolUpdateFailure>),
250    /// An `aave` core failure (DB/RPC/verification/market-not-found).
251    AaveUpdate(AaveRunError),
252    /// A command arm that `block_on`s the process-wide shared runtime was invoked
253    /// from inside an existing `tokio` runtime. `run_pool_update`/`run_aave_update`
254    /// ride `get_runtime()` and must not nest; the arms hold the same
255    /// constraint.
256    RuntimeNested,
257    /// The operator host refused a command: the `{"ok": false, "error": ...}`
258    /// frame, rendered as one line (ADR-051 D6).
259    OperatorRefused(String),
260    /// A protocol-level failure talking to the operator host: an unreachable
261    /// socket, a timed-out exchange, or a malformed/non-object/missing-`ok`
262    /// response frame.
263    OperatorProtocol(String),
264    /// A client-side wire-hygiene refusal (an unknown `cordon_*` key, an
265    /// empty posture patch, an unknown hop-family string), raised BEFORE the
266    /// socket is touched. Domain validation stays server-side.
267    OperatorHygiene(String),
268}
269
270impl CliError {
271    /// The operator-facing line for this failure.
272    #[must_use]
273    pub fn message(&self) -> String {
274        match self {
275            Self::BootRefused(message)
276            | Self::BlockResolution(message)
277            | Self::InvalidArgument(message)
278            | Self::OperatorRefused(message)
279            | Self::OperatorProtocol(message)
280            | Self::OperatorHygiene(message) => message.clone(),
281            Self::Aborted => "Aborted!".to_string(),
282            Self::DatabaseUpgradeRetired => {
283                "the database upgrades itself at open; for an explicit repair, run \
284                 `degenbot database heal`"
285                    .to_string()
286            }
287            Self::DatabaseForeign => {
288                "The database is unrecognized (a foreign SQLite file); refused.".to_string()
289            }
290            Self::DatabaseNothingToDo => {
291                "The database has no legacy history; there is nothing to cut over.".to_string()
292            }
293            Self::Database(err) => err.to_string(),
294            Self::Io(err) => err.to_string(),
295            Self::Config(err) => err.to_string(),
296            Self::UnknownChain { chain } => format!(
297                "Unknown chain {chain:?}: expected a chain slug (base, ethereum) or a numeric \
298                 chain id."
299            ),
300            Self::UnknownDeployment { chain_id, name } => {
301                format!("The deployments registry has no record for {name:?} on chain {chain_id}.")
302            }
303            Self::InvalidBlockTag(tag) => format!("Invalid block tag: {tag}"),
304            Self::InvalidAddress(address) => format!("Invalid address: {address}"),
305            Self::NoActiveAaveMarkets => "No active Aave markets found.".to_string(),
306            Self::PoolUpdate(failure) => failure.message(),
307            Self::AaveUpdate(err) => err.to_string(),
308            Self::RuntimeNested => "the command arms own their tokio runtime; do not run them \
309                 from inside an existing runtime"
310                .to_string(),
311        }
312    }
313}
314
315impl fmt::Display for CliError {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        f.write_str(&self.message())
318    }
319}
320
321impl std::error::Error for CliError {
322    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
323        match self {
324            Self::Database(err) => Some(err),
325            Self::Config(err) => Some(err),
326            Self::PoolUpdate(failure) => Some(&failure.error),
327            Self::AaveUpdate(err) => Some(err),
328            Self::Io(err) => Some(err),
329            _ => None,
330        }
331    }
332}
333
334/// Map a database error onto its typed console failure.
335///
336/// A foreign-file failure stays typed here — the facade must be able to point
337/// the operator at the right remedy without string matching.
338impl From<DbError> for CliError {
339    fn from(err: DbError) -> Self {
340        match err {
341            DbError::UnrecognizedSchema => Self::DatabaseForeign,
342            other => Self::Database(other),
343        }
344    }
345}
346
347/// Map a config-resolution error onto its typed console failure.
348impl From<ConfigError> for CliError {
349    fn from(err: ConfigError) -> Self {
350        Self::Config(err)
351    }
352}
353
354/// THE one `CliError → ExitCode` mapping site (ADR-051 D1).
355impl From<&CliError> for ExitCode {
356    fn from(err: &CliError) -> Self {
357        match err {
358            // FF-T1: the typed fleet boot refusal is the lone `EX_CONFIG` arm.
359            CliError::BootRefused(_) => Self::Config,
360            CliError::Aborted
361            | CliError::DatabaseUpgradeRetired
362            | CliError::DatabaseForeign
363            | CliError::DatabaseNothingToDo
364            | CliError::Database(_)
365            | CliError::Io(_)
366            | CliError::Config(_)
367            | CliError::UnknownChain { .. }
368            | CliError::UnknownDeployment { .. }
369            | CliError::InvalidBlockTag(_)
370            | CliError::BlockResolution(_)
371            | CliError::InvalidAddress(_)
372            | CliError::InvalidArgument(_)
373            | CliError::NoActiveAaveMarkets
374            | CliError::PoolUpdate(_)
375            | CliError::AaveUpdate(_)
376            | CliError::RuntimeNested
377            | CliError::OperatorRefused(_)
378            | CliError::OperatorProtocol(_)
379            | CliError::OperatorHygiene(_) => Self::Failure,
380        }
381    }
382}
383
384impl From<CliError> for ExitCode {
385    fn from(err: CliError) -> Self {
386        Self::from(&err)
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    //! The pool-failure rendering (the `pool update` diagnostic).
393    use super::*;
394    use degenbot_db::DbError;
395
396    #[test]
397    fn non_connection_failure_names_endpoint_chain_range_and_error() {
398        let failure = PoolUpdateFailure {
399            error: PoolRunError::Db(DbError::MissingRow("chunk row".to_string())),
400            rpc_url: "http://reth.local:8545".to_string(),
401            chain_id: 8453,
402            from_block: 26_055_206,
403            to_block: Some(26_059_263),
404            resume: None,
405        };
406        let message = failure.message();
407        assert!(message.contains("http://reth.local:8545"), "{message}");
408        assert!(message.contains("8453"), "{message}");
409        assert!(message.contains("blocks 26055206-26059263"), "{message}");
410        assert!(
411            message.contains("required row not found: chunk row"),
412            "{message}"
413        );
414        assert!(!message.contains("dropped mid-run"), "{message}");
415    }
416
417    #[test]
418    fn tip_run_reports_an_open_ended_range() {
419        let failure = PoolUpdateFailure {
420            error: PoolRunError::Db(DbError::MissingRow("tip".to_string())),
421            rpc_url: "http://reth.local:8545".to_string(),
422            chain_id: 1,
423            from_block: 5,
424            to_block: None,
425            resume: None,
426        };
427        assert!(failure
428            .message()
429            .contains("blocks 5 onward (the chain tip)"));
430    }
431
432    #[test]
433    fn resume_groups_render_current_behind_and_never_updated() {
434        let failure = PoolUpdateFailure {
435            error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
436            rpc_url: "http://reth.local:8545".to_string(),
437            chain_id: 8453,
438            from_block: 1,
439            to_block: Some(100),
440            resume: Some(vec![
441                ExchangeResumeState {
442                    name: "uniswap_v2".to_string(),
443                    last_update_block: Some(100),
444                },
445                ExchangeResumeState {
446                    name: "uniswap_v3".to_string(),
447                    last_update_block: Some(50),
448                },
449                ExchangeResumeState {
450                    name: "uniswap_v4".to_string(),
451                    last_update_block: None,
452                },
453            ]),
454        };
455        let message = failure.message();
456        assert!(
457            message.contains("current at the requested target: uniswap_v2"),
458            "{message}"
459        );
460        assert!(
461            message.contains("behind (last committed block): uniswap_v3 (block 50)"),
462            "{message}"
463        );
464        assert!(message.contains("never updated: uniswap_v4"), "{message}");
465    }
466
467    #[test]
468    fn a_tip_run_leaves_no_exchange_current_at_the_target() {
469        let failure = PoolUpdateFailure {
470            error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
471            rpc_url: "http://reth.local:8545".to_string(),
472            chain_id: 8453,
473            from_block: 1,
474            to_block: None,
475            resume: Some(vec![ExchangeResumeState {
476                name: "uniswap_v2".to_string(),
477                last_update_block: Some(26_059_263),
478            }]),
479        };
480        let message = failure.message();
481        assert!(
482            !message.contains("current at the requested target"),
483            "{message}"
484        );
485        assert!(
486            message.contains("behind (last committed block): uniswap_v2 (block 26059263)"),
487            "{message}"
488        );
489    }
490
491    #[test]
492    fn an_unavailable_snapshot_is_said_so() {
493        let failure = PoolUpdateFailure {
494            error: PoolRunError::Db(DbError::MissingRow("row".to_string())),
495            rpc_url: "http://reth.local:8545".to_string(),
496            chain_id: 8453,
497            from_block: 1,
498            to_block: Some(100),
499            resume: None,
500        };
501        assert!(failure
502            .message()
503            .contains("exchange cursor state unavailable"),);
504    }
505}