Skip to main content

qn/
errors.rs

1//! Top-level error type for the CLI and the SDK→user message mapping.
2
3use std::collections::BTreeSet;
4use std::path::PathBuf;
5
6use quicknode_sdk::errors::{HttpKind, SdkError};
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum CliError {
12    #[error("no API key found. Run 'qn auth login', or pass --api-key or --config-file")]
13    NoApiKey,
14
15    #[error("config file at {path} is invalid: {source}")]
16    BadConfig {
17        path: PathBuf,
18        #[source]
19        source: toml::de::Error,
20    },
21
22    #[error("could not write config file at {path}: {source}")]
23    ConfigWrite {
24        path: PathBuf,
25        #[source]
26        source: std::io::Error,
27    },
28
29    #[error("invalid argument: {0}")]
30    Arg(String),
31
32    #[error("operation cancelled")]
33    Cancelled,
34
35    #[error(
36        "operation requires confirmation; pass --yes to proceed without an interactive prompt"
37    )]
38    NeedsConfirmation,
39
40    #[error(transparent)]
41    Sdk(#[from] SdkError),
42
43    #[error(transparent)]
44    Io(#[from] std::io::Error),
45
46    #[error(transparent)]
47    Json(#[from] serde_json::Error),
48
49    #[error("could not serialize output: {0}")]
50    Format(String),
51}
52
53/// Maps a [`CliError`] to a process exit code per the plan.
54///
55/// - 0: success (never produced here)
56/// - 1: generic CLI failure (arg parse, IO, decode). clap usage errors are
57///   mapped to 1 in main.rs too, so 2 always and only means an API error.
58/// - 2: SdkError::Api (server returned a non-2xx)
59/// - 3: SdkError::Http (network failure)
60/// - 4: NoApiKey / BadConfig
61/// - 5: user cancelled or needs --yes
62pub fn exit_code_for(err: &CliError) -> i32 {
63    match err {
64        CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4,
65        CliError::Cancelled | CliError::NeedsConfirmation => 5,
66        CliError::Sdk(sdk) => match sdk {
67            SdkError::Api { .. } => 2,
68            SdkError::Http(_) => 3,
69            _ => 1,
70        },
71        _ => 1,
72    }
73}
74
75/// Renders the error to a human-friendly message using the real process argv
76/// for did-you-mean suggestions. Use [`render_with_argv`] from tests where the
77/// simulated argv differs from the process argv.
78///
79/// Verbose mode appends the underlying body / source where available.
80pub fn render(err: &CliError, verbose: bool) -> String {
81    let argv: Vec<String> = std::env::args().skip(1).collect();
82    render_with_argv(err, verbose, &argv)
83}
84
85/// Like [`render`] but uses the supplied argv values for did-you-mean lookup.
86pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String {
87    match err {
88        CliError::Sdk(SdkError::Api { status, body }) => {
89            render_api_error(status.as_u16(), body, verbose, argv)
90        }
91        CliError::Sdk(sdk @ SdkError::Http(inner)) => {
92            // The failed host varies: control-plane calls hit api.quicknode.com,
93            // RPC data-plane calls hit the endpoint host (*.quiknode.pro). Name
94            // the actual host from the reqwest error's URL when available rather
95            // than hardcoding one.
96            let host = inner.url().and_then(|u| u.host_str()).map(str::to_string);
97            let target = host
98                .map(|h| format!("'{h}'"))
99                .unwrap_or_else(|| "the Quicknode API".to_string());
100            let msg = match sdk.http_kind() {
101                Some(HttpKind::Timeout) => {
102                    format!("request to {target} timed out. Check your connection and try again.")
103                }
104                Some(HttpKind::Connect) => {
105                    format!("could not connect to {target}. Check your network.")
106                }
107                _ => format!("HTTP transport failure talking to {target}."),
108            };
109            if verbose {
110                format!("Error: {msg}\n{sdk}")
111            } else {
112                format!("Error: {msg}")
113            }
114        }
115        CliError::Sdk(SdkError::Decode { body, .. }) => {
116            if verbose {
117                format!("Error: unexpected response shape from API.\n{body}")
118            } else {
119                "Error: unexpected response shape from API. Re-run with --verbose to see the body."
120                    .to_string()
121            }
122        }
123        CliError::BadConfig { path, source } => {
124            if verbose {
125                format!(
126                    "Error: config file at {} is invalid: {source}",
127                    path.display()
128                )
129            } else {
130                format!(
131                    "Error: config file at {} is invalid. Re-run with --verbose for details.",
132                    path.display()
133                )
134            }
135        }
136        other => format!("Error: {other}"),
137    }
138}
139
140/// Status codes have a small set of canonical user-facing messages. Validation
141/// (400/422) gets the structured body treatment from `parse_api_body`.
142fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String {
143    let headline = match code {
144        400 | 422 => "invalid request.".to_string(),
145        401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(),
146        404 => "not found.".to_string(),
147        429 => "rate limited by the Quicknode API. Try again shortly.".to_string(),
148        500..=599 => format!(
149            "something went wrong (HTTP {code}). Please try again; if the problem persists, \
150             contact support at https://support.quicknode.com."
151        ),
152        _ => format!("API returned HTTP {code}."),
153    };
154
155    // For non-validation status codes, body is mostly noise (server stack traces,
156    // HTML error pages, etc). Only mine it for validation-class errors.
157    let parsed = if matches!(code, 400 | 422) {
158        parse_api_body(body, argv)
159    } else {
160        ParsedApiBody::default()
161    };
162
163    let mut out = format!("Error: {headline}");
164
165    if !parsed.bullets.is_empty() {
166        for bullet in &parsed.bullets {
167            out.push_str("\n  • ");
168            out.push_str(bullet);
169        }
170    } else if matches!(code, 400 | 422) && !body.is_empty() && !verbose {
171        // We tried to parse and got nothing useful; surface the raw body so
172        // the user isn't left with a bare "invalid request." line.
173        out.push('\n');
174        out.push_str(body.trim());
175    }
176
177    for hint in &parsed.hints {
178        out.push('\n');
179        out.push_str(hint);
180    }
181
182    if verbose && !body.is_empty() {
183        out.push('\n');
184        out.push_str(body);
185    } else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() {
186        out.push_str("\nRe-run with --verbose for the full response body.");
187    }
188
189    out
190}
191
192#[derive(Default)]
193struct ParsedApiBody {
194    bullets: Vec<String>,
195    hints: Vec<String>,
196}
197
198/// Parses a JSON-shaped API error body, extracting human-readable messages and
199/// (when the body contains "must be one of …" enum lists) appending
200/// did-you-mean suggestions against the user's argv.
201fn parse_api_body(body: &str, argv: &[String]) -> ParsedApiBody {
202    let mut out = ParsedApiBody::default();
203    if body.is_empty() {
204        return out;
205    }
206    let Ok(value) = serde_json::from_str::<Value>(body) else {
207        return out;
208    };
209
210    let mut raw_strings: Vec<String> = Vec::new();
211    collect_error_strings(&value, &mut raw_strings);
212    if raw_strings.is_empty() {
213        return out;
214    }
215
216    let mut seen: BTreeSet<String> = BTreeSet::new();
217    let mut fields_hinted: BTreeSet<String> = BTreeSet::new();
218
219    for s in raw_strings {
220        let trimmed = s.trim().to_string();
221        if trimmed.is_empty() || !seen.insert(trimmed.clone()) {
222            continue;
223        }
224        if is_generic_label(&trimmed) {
225            // Skip "Bad Request" / "Unauthorized" — these duplicate the headline.
226            continue;
227        }
228        let bullet = decorate_with_suggestion(&trimmed, argv, &mut fields_hinted);
229        out.bullets.push(bullet);
230    }
231
232    for field in &fields_hinted {
233        if let Some(hint) = field_hint(field) {
234            out.hints.push(hint.to_string());
235        }
236    }
237
238    out
239}
240
241/// Recursively walk a JSON value, pulling strings out of any key named
242/// `error`, `errors`, `message`, or `messages`. Accepts strings, arrays of
243/// strings, arrays of objects (recurse), and nested objects (recurse).
244fn collect_error_strings(value: &Value, out: &mut Vec<String>) {
245    const KEYS: &[&str] = &["errors", "error", "messages", "message"];
246    match value {
247        Value::Object(map) => {
248            for key in KEYS {
249                if let Some(v) = map.get(*key) {
250                    collect_strings_from(v, out);
251                }
252            }
253            // Also recurse into other object values so we can find nested
254            // error/message keys (e.g. NestJS wraps under `message.message`).
255            for (k, v) in map {
256                if !KEYS.contains(&k.as_str()) {
257                    collect_error_strings(v, out);
258                }
259            }
260        }
261        Value::Array(arr) => {
262            for v in arr {
263                collect_error_strings(v, out);
264            }
265        }
266        _ => {}
267    }
268}
269
270/// Helper: when we hit one of the error keys, accept multiple shapes.
271fn collect_strings_from(value: &Value, out: &mut Vec<String>) {
272    match value {
273        Value::String(s) => out.push(s.clone()),
274        Value::Array(arr) => {
275            for v in arr {
276                match v {
277                    Value::String(s) => out.push(s.clone()),
278                    Value::Object(_) => collect_error_strings(v, out),
279                    _ => {}
280                }
281            }
282        }
283        Value::Object(_) => collect_error_strings(value, out),
284        _ => {}
285    }
286}
287
288/// Returns the bullet text, possibly with a `did you mean '…'?` suffix and a
289/// `(N more)` truncation marker. Also records which fields had enum lists so
290/// the caller can attach helper hints.
291fn decorate_with_suggestion(
292    raw: &str,
293    argv: &[String],
294    fields_hinted: &mut BTreeSet<String>,
295) -> String {
296    let Some((field, candidates)) = parse_must_be_one_of(raw) else {
297        return raw.to_string();
298    };
299
300    fields_hinted.insert(field.clone());
301
302    // Find the argv value that's closest to any candidate, then attach DYM if
303    // the best match is within threshold.
304    let best = best_suggestion(argv, &candidates);
305
306    let display = truncate_candidate_list(&candidates, 5);
307    let mut bullet = format!("{field} must be one of: {display}");
308    if let Some((user_value, suggestion)) = best {
309        bullet.push_str(&format!(
310            " — did you mean '{suggestion}' (you passed '{user_value}')?"
311        ));
312    }
313    bullet
314}
315
316/// Parse `"<field> must be one of [the following values:] X, Y, Z"`.
317/// Returns the field name and the candidate list.
318fn parse_must_be_one_of(s: &str) -> Option<(String, Vec<String>)> {
319    let (field_part, rest) = s.split_once(" must be one of")?;
320    let field = field_part.trim();
321    if field.is_empty()
322        || !field
323            .chars()
324            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
325    {
326        return None;
327    }
328    // After "must be one of" we accept any of: " the following values: X, Y",
329    // ": X, Y", or " X, Y" (rare but possible).
330    let list_part = rest
331        .strip_prefix(" the following values: ")
332        .or_else(|| rest.strip_prefix(": "))
333        .or_else(|| rest.strip_prefix(' '))
334        .unwrap_or(rest)
335        .trim_end_matches('.');
336    let candidates: Vec<String> = list_part
337        .split(", ")
338        .map(|c| c.trim().to_string())
339        .filter(|c| !c.is_empty())
340        .collect();
341    if candidates.len() < 2 {
342        return None;
343    }
344    Some((field.to_string(), candidates))
345}
346
347/// Find the (argv-value, candidate) pair with smallest Levenshtein distance,
348/// gated on: distance ≤ 3 AND ≥ 3 leading chars shared with the candidate.
349fn best_suggestion(argv: &[String], candidates: &[String]) -> Option<(String, String)> {
350    let mut best: Option<(usize, String, String)> = None;
351    for arg in argv {
352        // Skip flags themselves and obviously-non-value tokens.
353        if arg.starts_with('-') || arg.is_empty() || arg.len() < 2 {
354            continue;
355        }
356        for cand in candidates {
357            let d = levenshtein(arg, cand);
358            if d > 3 {
359                continue;
360            }
361            if shared_prefix_len(arg, cand) < 3 {
362                continue;
363            }
364            match best.as_ref() {
365                None => best = Some((d, arg.clone(), cand.clone())),
366                Some((cur, _, _)) if d < *cur => best = Some((d, arg.clone(), cand.clone())),
367                _ => {}
368            }
369        }
370    }
371    best.map(|(_, a, c)| (a, c))
372}
373
374fn shared_prefix_len(a: &str, b: &str) -> usize {
375    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
376}
377
378/// Classic O(n*m) Levenshtein distance. n,m are tiny here (≤ ~40 chars), so
379/// this is fine.
380fn levenshtein(a: &str, b: &str) -> usize {
381    let a: Vec<char> = a.chars().collect();
382    let b: Vec<char> = b.chars().collect();
383    if a.is_empty() {
384        return b.len();
385    }
386    if b.is_empty() {
387        return a.len();
388    }
389    let mut prev: Vec<usize> = (0..=b.len()).collect();
390    let mut curr = vec![0usize; b.len() + 1];
391    for (i, ca) in a.iter().enumerate() {
392        curr[0] = i + 1;
393        for (j, cb) in b.iter().enumerate() {
394            let cost = if ca == cb { 0 } else { 1 };
395            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
396        }
397        std::mem::swap(&mut prev, &mut curr);
398    }
399    prev[b.len()]
400}
401
402fn truncate_candidate_list(candidates: &[String], keep: usize) -> String {
403    if candidates.len() <= keep {
404        return candidates.join(", ");
405    }
406    let shown = candidates[..keep].join(", ");
407    let extra = candidates.len() - keep;
408    format!("{shown} ({extra} more)")
409}
410
411/// Maps known server-side field names to a follow-up command the user can run
412/// to discover valid values.
413/// Skip standard HTTP status-phrase strings that duplicate the headline.
414fn is_generic_label(s: &str) -> bool {
415    matches!(
416        s,
417        "Bad Request"
418            | "Unauthorized"
419            | "Forbidden"
420            | "Not Found"
421            | "Unprocessable Entity"
422            | "Too Many Requests"
423            | "Internal Server Error"
424            | "Service Unavailable"
425    )
426}
427
428fn field_hint(field: &str) -> Option<&'static str> {
429    match field {
430        "network" => Some("Run 'qn chain list' to see supported networks."),
431        "chain" => Some("Run 'qn chain list' to see supported chains."),
432        _ => None,
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use quicknode_sdk::errors::SdkError;
440
441    fn api_err_with(code: u16, body: &str) -> CliError {
442        CliError::Sdk(SdkError::Api {
443            status: reqwest::StatusCode::from_u16(code).unwrap(),
444            body: body.to_string(),
445        })
446    }
447
448    fn api_err(code: u16) -> CliError {
449        api_err_with(code, "{\"message\":\"boom\"}")
450    }
451
452    #[test]
453    fn exit_code_api_is_2() {
454        assert_eq!(exit_code_for(&api_err(404)), 2);
455    }
456
457    #[test]
458    fn exit_code_no_api_key_is_4() {
459        assert_eq!(exit_code_for(&CliError::NoApiKey), 4);
460    }
461
462    #[test]
463    fn exit_code_cancelled_is_5() {
464        assert_eq!(exit_code_for(&CliError::Cancelled), 5);
465    }
466
467    #[test]
468    fn renders_401_as_unauthorized() {
469        let msg = render(&api_err(401), false);
470        assert!(msg.contains("unauthorized"), "got: {msg}");
471    }
472
473    #[test]
474    fn renders_429_as_rate_limited() {
475        let msg = render(&api_err(429), false);
476        assert!(msg.contains("rate limited"), "got: {msg}");
477    }
478
479    #[test]
480    fn renders_5xx_with_status() {
481        let msg = render(&api_err(503), false);
482        assert!(msg.contains("503"), "got: {msg}");
483    }
484
485    #[test]
486    fn verbose_404_includes_body() {
487        let msg = render(&api_err(404), true);
488        assert!(msg.contains("boom"), "got: {msg}");
489    }
490
491    #[test]
492    fn non_verbose_404_omits_body() {
493        let msg = render(&api_err(404), false);
494        assert!(!msg.contains("boom"), "got: {msg}");
495    }
496
497    // ---- body parsing ----
498
499    #[test]
500    fn nestjs_shape_extracts_bullets() {
501        let body = r#"{"statusCode":400,"message":{"message":["network must be one of the following values: ethereum-mainnet, ethereum-sepolia, solana-mainnet","status must be one of the following values: active, paused, terminated"],"error":"Bad Request"}}"#;
502        let msg = render(&api_err_with(400, body), false);
503        assert!(msg.starts_with("Error: invalid request."), "got: {msg}");
504        assert!(msg.contains("• network must be one of:"), "got: {msg}");
505        assert!(msg.contains("• status must be one of:"), "got: {msg}");
506    }
507
508    #[test]
509    fn admin_shape_extracts_error_string() {
510        let body = r#"{"data":null,"error":"undefined method `chain' for nil"}"#;
511        let msg = render(&api_err_with(400, body), false);
512        assert!(msg.contains("undefined method"), "got: {msg}");
513    }
514
515    #[test]
516    fn empty_body_400_falls_through() {
517        let msg = render(&api_err_with(400, ""), false);
518        assert_eq!(msg, "Error: invalid request.");
519    }
520
521    #[test]
522    fn garbage_non_json_body_falls_back_to_raw() {
523        let body = "<html>oops</html>";
524        let msg = render(&api_err_with(400, body), false);
525        assert!(msg.contains("<html>oops</html>"), "got: {msg}");
526    }
527
528    #[test]
529    fn generic_errors_array_of_strings() {
530        let body = r#"{"errors":["first thing wrong","second thing wrong"]}"#;
531        let msg = render(&api_err_with(400, body), false);
532        assert!(msg.contains("• first thing wrong"), "got: {msg}");
533        assert!(msg.contains("• second thing wrong"), "got: {msg}");
534    }
535
536    #[test]
537    fn generic_errors_array_of_objects() {
538        let body = r#"{"errors":[{"message":"thing one"},{"message":"thing two"}]}"#;
539        let msg = render(&api_err_with(400, body), false);
540        assert!(msg.contains("• thing one"), "got: {msg}");
541        assert!(msg.contains("• thing two"), "got: {msg}");
542    }
543
544    #[test]
545    fn dedupes_repeated_strings() {
546        let body = r#"{"error":"same thing","message":"same thing"}"#;
547        let msg = render(&api_err_with(400, body), false);
548        let count = msg.matches("same thing").count();
549        assert_eq!(count, 1, "expected dedupe, got: {msg}");
550    }
551
552    #[test]
553    fn truncates_long_enum_list() {
554        // 10 candidates, only the first 5 should render inline.
555        let body = r#"{"message":"x must be one of a, b, c, d, e, f, g, h, i, j"}"#;
556        let msg = render(&api_err_with(400, body), false);
557        assert!(msg.contains("a, b, c, d, e (5 more)"), "got: {msg}");
558    }
559
560    #[test]
561    fn field_hint_appended_for_network() {
562        let body = r#"{"message":"network must be one of: ethereum-mainnet, solana-mainnet"}"#;
563        let msg = render(&api_err_with(400, body), false);
564        assert!(msg.contains("qn chain list"), "got: {msg}");
565    }
566
567    #[test]
568    fn verbose_appends_full_body() {
569        let body = r#"{"message":["network must be one of: a, b, c"]}"#;
570        let msg = render(&api_err_with(400, body), true);
571        assert!(msg.contains(body), "got: {msg}");
572    }
573
574    #[test]
575    fn levenshtein_basic() {
576        assert_eq!(levenshtein("", ""), 0);
577        assert_eq!(levenshtein("a", ""), 1);
578        assert_eq!(levenshtein("", "abc"), 3);
579        assert_eq!(levenshtein("kitten", "sitting"), 3);
580        assert_eq!(levenshtein("ethereum-mainnet", "ethereum-mainnet"), 0);
581        assert_eq!(levenshtein("ethereum-mainnetsds", "ethereum-mainnet"), 3);
582    }
583
584    #[test]
585    fn parse_must_be_one_of_happy_path() {
586        let (f, c) =
587            parse_must_be_one_of("network must be one of the following values: a, b, c").unwrap();
588        assert_eq!(f, "network");
589        assert_eq!(c, vec!["a", "b", "c"]);
590    }
591
592    #[test]
593    fn parse_must_be_one_of_no_following_values_prefix() {
594        let (f, c) = parse_must_be_one_of("status must be one of active, paused").unwrap();
595        assert_eq!(f, "status");
596        assert_eq!(c, vec!["active", "paused"]);
597    }
598
599    #[test]
600    fn parse_must_be_one_of_rejects_unrelated_strings() {
601        assert!(parse_must_be_one_of("some random error").is_none());
602    }
603
604    #[test]
605    fn truncate_candidate_list_under_keep_returns_all() {
606        assert_eq!(
607            truncate_candidate_list(&["a".into(), "b".into()], 5),
608            "a, b"
609        );
610    }
611
612    #[test]
613    fn best_suggestion_picks_closest_within_threshold() {
614        let candidates: Vec<String> =
615            vec!["ethereum-mainnet", "ethereum-sepolia", "solana-mainnet"]
616                .into_iter()
617                .map(String::from)
618                .collect();
619        let argv = vec!["ethereum-mainnetsds".to_string()];
620        let suggestion = best_suggestion(&argv, &candidates);
621        assert_eq!(
622            suggestion,
623            Some(("ethereum-mainnetsds".into(), "ethereum-mainnet".into()))
624        );
625    }
626
627    #[test]
628    fn best_suggestion_returns_none_if_too_far() {
629        let candidates: Vec<String> = vec!["ethereum-mainnet"]
630            .into_iter()
631            .map(String::from)
632            .collect();
633        let argv = vec!["sfjla".to_string()];
634        assert_eq!(best_suggestion(&argv, &candidates), None);
635    }
636
637    #[test]
638    fn best_suggestion_ignores_flag_tokens() {
639        let candidates: Vec<String> = vec!["chain"].into_iter().map(String::from).collect();
640        let argv = vec!["--chain".to_string()];
641        // "--chain" starts with "-", should be skipped.
642        assert_eq!(best_suggestion(&argv, &candidates), None);
643    }
644
645    #[test]
646    fn renders_5xx_skips_body_parsing() {
647        // We don't want stack-trace HTML on a 500 to be parsed as bullets.
648        let body = r#"{"message":"internal error"}"#;
649        let msg = render(&api_err_with(500, body), false);
650        assert!(!msg.contains("•"), "got: {msg}");
651    }
652}