Skip to main content

raqeem_core/
error.rs

1use std::path::PathBuf;
2
3/// How much of an endpoint's response body to quote back in an error. Enough to show a
4/// JSON error object or the top of an HTML error page; not so much that a misconfigured
5/// endpoint returning a megabyte of markup floods the terminal.
6const BODY_EXCERPT_LIMIT: usize = 512;
7
8/// What a URL looks like once its credentials are removed.
9const REDACTED: &str = "***";
10
11/// Everything that can go wrong on the way from an audio file to a transcript.
12///
13/// Non-exhaustive: matching on it must carry a `_` arm, so that a future variant is not a
14/// breaking change.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum Error {
18    #[error("could not read audio file {path}: {source}")]
19    ReadFile {
20        path: PathBuf,
21        source: std::io::Error,
22    },
23
24    /// The HTTP client could not be constructed — in practice, TLS backend
25    /// initialization failing.
26    #[error("could not build the HTTP client: {source}")]
27    Client { source: reqwest::Error },
28
29    // `source` is stripped of its URL before it gets here: reqwest appends
30    // " for url (...)" to its own Display, which would print the endpoint a second time
31    // and, if the URL carries userinfo credentials, print those to stderr twice over.
32    #[error("request to {url} failed: {source}")]
33    Http { url: String, source: reqwest::Error },
34
35    #[error("endpoint returned HTTP {status}: {body}")]
36    Api { status: u16, body: String },
37
38    /// The endpoint answered with a redirect instead of a transcript.
39    ///
40    /// Deliberately not followed. A 302/303 downgrades POST to GET and drops the body, so
41    /// the audio would never be uploaded and whatever the target returned would be parsed
42    /// as a transcript — a silently wrong answer. A 307/308 preserves the method but
43    /// cannot replay a streamed body. Either way the configuration is what needs fixing.
44    #[error(
45        "endpoint redirected (HTTP {status}{})—point --endpoint at the final URL instead; \
46         redirects are not followed, because following one would either upload nothing or \
47         fabricate a transcript from the wrong response",
48        .location.as_ref().map(|l| format!(" to {l}")).unwrap_or_default()
49    )]
50    Redirect {
51        status: u16,
52        location: Option<String>,
53    },
54
55    /// The endpoint URL could not be used — empty, unparseable, or not http(s).
56    #[error("--endpoint {url:?} is not a usable URL: {reason}")]
57    InvalidEndpoint { url: String, reason: String },
58
59    /// The response was larger than [`MAX_RESPONSE_BYTES`](crate::MAX_RESPONSE_BYTES).
60    #[error(
61        "endpoint response is too large ({got} bytes, limit {limit}); a transcript is \
62             normally a few kilobytes, so this endpoint is probably not returning one"
63    )]
64    ResponseTooLarge { got: u64, limit: u64 },
65
66    #[error("could not parse endpoint response (expected a JSON object with a string \"text\" field), got: {body}")]
67    BadResponse { body: String },
68}
69
70/// Strip any credentials out of a URL before it goes anywhere a human can read.
71///
72/// `https://bob:hunter2@host/v1` becomes `https://***:***@host/v1`. Endpoints sitting
73/// behind basic auth are ordinary, and an error message ends up in terminals, CI logs and
74/// pasted issue reports.
75///
76/// A string that doesn't parse as a URL has no userinfo to leak, so it passes through —
77/// which also keeps the message useful when the URL itself is what's malformed.
78pub(crate) fn redact_url(raw: &str) -> String {
79    let Ok(mut parsed) = url::Url::parse(raw) else {
80        return raw.to_string();
81    };
82    if parsed.username().is_empty() && parsed.password().is_none() {
83        return raw.to_string();
84    }
85    // Both setters only fail on URLs that cannot have credentials (`mailto:` and such),
86    // and those have none to begin with — so falling back to the parsed form is safe.
87    let _ = parsed.set_username(REDACTED);
88    let _ = parsed.set_password(Some(REDACTED));
89    parsed.to_string()
90}
91
92/// Blank out a secret wherever it appears in text we're about to show someone.
93///
94/// An endpoint that reflects the `Authorization` header into its error body — hostile, or
95/// merely verbose — would otherwise put the caller's own API key on their terminal. We
96/// hold the key at that moment, so removing it costs one scan.
97pub(crate) fn scrub_secret(text: String, secret: Option<&str>) -> String {
98    match secret {
99        Some(s) if !s.is_empty() => text.replace(s, REDACTED),
100        _ => text,
101    }
102}
103
104/// Trim a response body to something safe to put in an error message, marking it when cut.
105///
106/// Truncates on a character boundary — endpoint errors are often UTF-8 Arabic, and slicing
107/// a `String` mid-codepoint panics.
108pub(crate) fn excerpt(body: String) -> String {
109    if body.len() <= BODY_EXCERPT_LIMIT {
110        return body;
111    }
112    let cut = (0..=BODY_EXCERPT_LIMIT)
113        .rev()
114        .find(|&i| body.is_char_boundary(i))
115        .unwrap_or(0);
116    format!("{}… ({} bytes total)", &body[..cut], body.len())
117}
118
119pub type Result<T> = std::result::Result<T, Error>;
120
121#[cfg(test)]
122mod tests {
123    use super::{excerpt, redact_url, scrub_secret, BODY_EXCERPT_LIMIT};
124
125    #[test]
126    fn a_url_password_never_survives_redaction() {
127        let out = redact_url("https://bob:hunter2@host/v1/audio/transcriptions");
128        assert!(!out.contains("hunter2"), "{out}");
129        assert!(!out.contains("bob"), "{out}");
130        assert!(
131            out.contains("host"),
132            "the host is what makes it diagnosable: {out}"
133        );
134    }
135
136    #[test]
137    fn a_username_alone_is_still_redacted() {
138        let out = redact_url("https://bob@host/v1");
139        assert!(!out.contains("bob"), "{out}");
140    }
141
142    #[test]
143    fn a_url_without_credentials_is_left_alone() {
144        let plain = "https://api.cohere.com/v2/audio/transcriptions";
145        assert_eq!(redact_url(plain), plain);
146    }
147
148    /// A malformed URL has no userinfo to leak, and echoing it verbatim is what makes the
149    /// "your endpoint isn't a URL" case diagnosable.
150    #[test]
151    fn an_unparseable_url_passes_through() {
152        assert_eq!(redact_url("not-a-url"), "not-a-url");
153        assert_eq!(redact_url(""), "");
154    }
155
156    #[test]
157    fn a_reflected_api_key_is_scrubbed_from_a_body() {
158        let body = r#"{"error": "bad key: Bearer sk-LEAKME-9999"}"#.to_string();
159        let out = scrub_secret(body, Some("sk-LEAKME-9999"));
160        assert!(!out.contains("sk-LEAKME-9999"), "{out}");
161        assert!(out.contains("bad key"), "the diagnosis survives: {out}");
162    }
163
164    #[test]
165    fn scrubbing_without_a_key_changes_nothing() {
166        let body = "plain failure".to_string();
167        assert_eq!(scrub_secret(body.clone(), None), body);
168        assert_eq!(scrub_secret(body.clone(), Some("")), body);
169    }
170
171    #[test]
172    fn a_short_body_is_untouched() {
173        assert_eq!(excerpt("unauthorized".into()), "unauthorized");
174    }
175
176    #[test]
177    fn a_long_body_is_cut_and_marked() {
178        let out = excerpt("x".repeat(BODY_EXCERPT_LIMIT * 3));
179        assert!(
180            out.len() < BODY_EXCERPT_LIMIT * 2,
181            "still huge: {}",
182            out.len()
183        );
184        assert!(out.contains("bytes total"), "{out}");
185    }
186
187    #[test]
188    fn cutting_multibyte_arabic_does_not_panic() {
189        // Every char is 2 bytes, so the limit lands mid-codepoint if sliced naively.
190        let out = excerpt("ط".repeat(BODY_EXCERPT_LIMIT));
191        assert!(out.contains("bytes total"), "{out}");
192    }
193}