bee/swarm/errors.rs
1//! Crate-level error type. Mirrors bee-go's `BeeError` /
2//! `BeeArgumentError` / `BeeResponseError` hierarchy as a single Rust
3//! enum so callers can `match` on the variant.
4
5use std::fmt;
6
7use url::Url;
8
9/// Return a printable form of `url` with the query string and
10/// fragment stripped. Used by the HTTP send path and error formatter
11/// so that values the request put in the query string —
12/// SOC signatures (`?sig=`), Act publisher keys (`?recipient=`),
13/// or an auth token mistakenly passed there — never end up in logs
14/// or panics. The path is preserved (path segments are hex /
15/// identifier-only and considered public).
16pub fn redact_url(url: &Url) -> String {
17 let mut u = url.clone();
18 u.set_query(None);
19 u.set_fragment(None);
20 u.to_string()
21}
22
23/// Result alias used across the crate.
24pub type Result<T, E = Error> = std::result::Result<T, E>;
25
26/// Cap on captured response bodies so a huge error page can't OOM.
27pub const RESPONSE_BODY_CAP: usize = 4096;
28
29/// All errors surfaced from `bee-rs`.
30///
31/// - [`Error::Argument`] — caller passed invalid input. Mirrors
32/// bee-js / bee-go `BeeArgumentError`.
33/// - [`Error::Response`] — Bee returned a non-2xx status. Mirrors
34/// bee-js / bee-go `BeeResponseError`.
35/// - [`Error::LengthMismatch`] — typed-byte constructor rejected a
36/// value whose length didn't match any allowed size.
37/// - [`Error::Hex`] — hex decoding failed.
38/// - [`Error::Crypto`] — secp256k1 / keccak operation failed.
39/// - [`Error::Json`] — JSON decode/encode failed.
40/// - [`Error::Transport`] — transport error from `reqwest`.
41/// - [`Error::Other`] — fallback wrapper for anything else.
42#[derive(thiserror::Error, Debug)]
43pub enum Error {
44 /// Invalid argument from the caller.
45 #[error("{message}")]
46 Argument {
47 /// Human-readable description of what was invalid.
48 message: String,
49 },
50
51 /// Non-2xx HTTP response from Bee.
52 #[error("{method} {url}: {status} {status_text}")]
53 Response {
54 /// HTTP method of the failed request.
55 method: String,
56 /// Full URL of the failed request.
57 url: String,
58 /// Numeric status code.
59 status: u16,
60 /// `<code> <reason>` (e.g. `"422 Unprocessable Entity"`).
61 status_text: String,
62 /// Response body, truncated to [`RESPONSE_BODY_CAP`] bytes.
63 body: Vec<u8>,
64 },
65
66 /// Typed-byte constructor received a value of the wrong length.
67 #[error("invalid {kind} length: got {got}, expected {expected:?}")]
68 LengthMismatch {
69 /// The Rust type that rejected the input (`"Reference"`, `"BatchId"`, ...).
70 kind: &'static str,
71 /// Lengths the type accepts.
72 expected: &'static [usize],
73 /// Length actually provided.
74 got: usize,
75 },
76
77 /// Hex decoding failed.
78 #[error("invalid hex: {0}")]
79 Hex(#[from] hex::FromHexError),
80
81 /// Crypto primitive (secp256k1 / keccak) failed.
82 #[error("crypto: {0}")]
83 Crypto(String),
84
85 /// JSON encode/decode failed.
86 #[error("json: {0}")]
87 Json(#[from] serde_json::Error),
88
89 /// Transport-level error (TCP / TLS / DNS).
90 #[error("transport: {0}")]
91 Transport(#[from] reqwest::Error),
92
93 /// Catch-all for errors that don't fit the specific variants.
94 #[error("{0}")]
95 Other(String),
96}
97
98impl Error {
99 /// Build an [`Error::Argument`].
100 pub fn argument<M: Into<String>>(msg: M) -> Self {
101 Error::Argument {
102 message: msg.into(),
103 }
104 }
105
106 /// Build an [`Error::Crypto`].
107 pub fn crypto<M: fmt::Display>(msg: M) -> Self {
108 Error::Crypto(msg.to_string())
109 }
110
111 /// True if this error is an HTTP response error.
112 pub fn is_response(&self) -> bool {
113 matches!(self, Error::Response { .. })
114 }
115
116 /// Get the HTTP status if this error wraps a non-2xx response.
117 pub fn status(&self) -> Option<u16> {
118 match self {
119 Error::Response { status, .. } => Some(*status),
120 _ => None,
121 }
122 }
123}
124
125/// Build an [`Error::Response`] from a `reqwest::Response`. Reads up to
126/// [`RESPONSE_BODY_CAP`] bytes of the body so a giant error page can't
127/// blow up memory.
128pub async fn response_error_from(resp: reqwest::Response) -> Error {
129 let method = resp.url().as_str().to_owned();
130 // reqwest does not expose the request method on Response. Callers
131 // that need the method should wrap with their own context — this
132 // helper is for the common case where Response is the only thing
133 // available. We store the URL twice (in `method` slot too) when
134 // method is unknown; callers that care use [`Error::response`] below.
135 let url = method.clone();
136 let status = resp.status();
137 let status_text = status.canonical_reason().unwrap_or("").to_string();
138 let body = resp
139 .bytes()
140 .await
141 .map(|b| {
142 let n = b.len().min(RESPONSE_BODY_CAP);
143 b.slice(..n).to_vec()
144 })
145 .unwrap_or_default();
146 Error::Response {
147 method: String::new(),
148 url,
149 status: status.as_u16(),
150 status_text: format!("{} {}", status.as_u16(), status_text),
151 body,
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn length_mismatch_displays() {
161 let e = Error::LengthMismatch {
162 kind: "Reference",
163 expected: &[32, 64],
164 got: 16,
165 };
166 assert_eq!(
167 format!("{e}"),
168 "invalid Reference length: got 16, expected [32, 64]"
169 );
170 }
171
172 #[test]
173 fn argument_helper() {
174 let e = Error::argument("bad input");
175 assert!(matches!(e, Error::Argument { .. }));
176 assert_eq!(format!("{e}"), "bad input");
177 }
178}