Skip to main content

sendra_core/
error.rs

1//! Every way loading or sending a request can fail: [`SendraError`].
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::environment::{describe_captured, describe_environment, describe_variables};
7use crate::script;
8
9/// Every way loading or sending a request can fail.
10///
11/// Typed rather than `anyhow` so front-ends can branch on the variant (e.g. a
12/// TUI showing a "file missing" prompt vs. a network retry).
13#[derive(Debug, thiserror::Error)]
14pub enum SendraError {
15    #[error("could not read request file `{path}`")]
16    Io {
17        path: PathBuf,
18        #[source]
19        source: std::io::Error,
20    },
21
22    #[error("could not parse request file `{path}`")]
23    Parse {
24        path: PathBuf,
25        #[source]
26        source: serde_yaml::Error,
27    },
28
29    /// YAML that did not come from a file on disk (string input, tests).
30    #[error("could not parse request")]
31    ParseStr(#[source] serde_yaml::Error),
32
33    /// A document could not be serialized back to YAML —
34    /// [`Document::to_yaml_string`](crate::Document::to_yaml_string).
35    ///
36    /// In practice this should never happen for a `Document` built by
37    /// [`Document::from_path`](crate::Document::from_path)/[`from_yaml_str`](crate::Document::from_yaml_str):
38    /// every field that came from real YAML serializes back out the same
39    /// way. But `serde_yaml::to_string` still returns a `Result`, and
40    /// `.unwrap()`-ing it would turn a theoretical serialization bug into a
41    /// panic on save instead of a message a front-end can show and recover
42    /// from.
43    #[error("could not serialize this document back to YAML")]
44    Serialize(#[source] serde_yaml::Error),
45
46    /// A document could not be written back to disk after an edit —
47    /// [`Document::save_to_path`](crate::Document::save_to_path). The save
48    /// half of [`Io`](Self::Io), kept separate because the message has to say
49    /// "write" rather than "read", and because a write failure here means the
50    /// edit was never persisted: the file at `path` is left exactly as it was
51    /// before the save was attempted (see `save_to_path`'s own doc comment
52    /// for why the write can never leave `path` half-written).
53    #[error("could not write collection file `{path}`")]
54    SaveIo {
55        path: PathBuf,
56        #[source]
57        source: std::io::Error,
58    },
59
60    #[error("header `{name}` is not valid: {reason}")]
61    InvalidHeader { name: String, reason: String },
62
63    #[error("request to `{url}` failed")]
64    Network {
65        url: String,
66        #[source]
67        source: reqwest::Error,
68    },
69
70    /// The request did not finish inside the configured timeout.
71    ///
72    /// Split out of [`Network`](Self::Network) because it is the one network
73    /// failure whose cause is a *Sendra setting*. Every other one — DNS,
74    /// refused connection, TLS — is a statement about the network or the
75    /// server, and the fix is out there; this one says the server was still
76    /// working when Sendra stopped waiting, and the fix may well be a line in
77    /// `.sendra/config.yaml`. Folded into `Network`, all a user got was
78    /// "request to `x` failed / caused by: operation timed out", which never
79    /// mentions that Sendra imposed the limit or what it was set to.
80    ///
81    /// Carries the limit that was actually applied — the resolved
82    /// [`Config::timeout`](crate::Config::timeout), not the raw
83    /// `timeout_seconds` key, which may not have been set at all — so the
84    /// message can name it whether it came from a config file or from
85    /// [`DEFAULT_TIMEOUT`](crate::config::DEFAULT_TIMEOUT).
86    ///
87    /// The whole-request timeout covers connect, send *and* body read, so this
88    /// is raised from either half of [`send_prepared`](crate::send_prepared): a
89    /// server that accepts the connection and then dribbles the body out too
90    /// slowly times out here exactly like one that never answers at all.
91    #[error("request to `{url}` timed out after {}s", .timeout.as_secs_f64())]
92    Timeout {
93        url: String,
94        /// The limit that was exceeded, as applied to the client.
95        timeout: Duration,
96        /// reqwest's own error, kept so the cause chain still shows where in
97        /// the request the clock ran out.
98        #[source]
99        source: reqwest::Error,
100    },
101
102    /// The HTTP client itself could not be built, so nothing was sent and
103    /// nothing will be: this is a failure of the run's configuration (a TLS
104    /// backend that will not initialise, say), not of one request. Separate
105    /// from [`Network`](Self::Network) because there is no URL to name — the
106    /// client is built once for the whole run, before any request is looked
107    /// at.
108    #[error("could not build the HTTP client")]
109    Client(#[source] reqwest::Error),
110
111    /// A named request was asked for, but the collection has no such name.
112    ///
113    /// Carries the names that *are* available so a front-end can list them (or
114    /// offer a "did you mean") without re-reading the file.
115    #[error("no request named `{name}` in this collection (available: {})", .available.join(", "))]
116    RequestNotFound {
117        name: String,
118        available: Vec<String>,
119    },
120
121    /// A name was asked for, but the file holds a single request rather than a
122    /// collection, so there is nothing to select from.
123    #[error(
124        "cannot select request `{name}`: this file defines a single request, not a collection"
125    )]
126    NotACollection { name: String },
127
128    /// The file parsed as a collection but broke a rule serde cannot express:
129    /// `requests` must be non-empty, every request must have a `name`, and
130    /// those names must be unique.
131    #[error("invalid collection: {reason}")]
132    InvalidCollection { reason: String },
133
134    /// A single request broke a rule serde cannot express: at most one of
135    /// `body`/`json`/`body_file`/`form`/`multipart` may be set, and each
136    /// `multipart` part needs exactly one of `value`/`path`. Raised at parse
137    /// time — for a collection, wrapped into [`InvalidCollection`](Self::InvalidCollection)
138    /// with which request it was, the same way a duplicate name is.
139    #[error("invalid request: {reason}")]
140    InvalidRequest { reason: String },
141
142    /// A `body_file` (or a multipart `path`) named a file that could not be
143    /// read, or one whose content is not valid UTF-8. Distinct from
144    /// [`Io`](Self::Io), which is about the request *file itself* not being
145    /// readable — this is about a file the request *references*, resolved
146    /// relative to the request file's own directory. See
147    /// [`Request::resolve_body`](crate::Request::resolve_body).
148    #[error("could not read request body file `{path}`")]
149    BodyFileIo {
150        path: PathBuf,
151        #[source]
152        source: std::io::Error,
153    },
154
155    /// A `client_cert`/`client_key` path (from either config file, or
156    /// `--client-cert`/`--client-key`) named a file that could not be read.
157    /// Distinct from [`Client`](Self::Client), which wraps only a
158    /// `reqwest::Error`: reading the file happens before reqwest is ever
159    /// involved, and the message needs to say which path was the problem.
160    #[error("could not read client certificate file `{path}`")]
161    ClientCertIo {
162        path: PathBuf,
163        #[source]
164        source: std::io::Error,
165    },
166
167    /// Only one of `client_cert`/`client_key` — from config, `--client-cert`/
168    /// `--client-key`, or a mix of both — resolved to a path. A client
169    /// certificate and its private key are only meaningful as a pair; sending
170    /// half of one silently would be worse than refusing to build the client
171    /// at all.
172    #[error("client_cert/client_key must both be set, but only the {which} was")]
173    ClientCertIncomplete { which: &'static str },
174
175    /// A config file was found but could not be read. Separate from [`Io`](Self::Io)
176    /// so a front-end can say "your config is broken" rather than "your request
177    /// file is broken" — the user did not name this path on the command line
178    /// and needs to be told which file to go and fix.
179    #[error("could not read config file `{path}`")]
180    ConfigIo {
181        path: PathBuf,
182        #[source]
183        source: std::io::Error,
184    },
185
186    /// A config file was read but is not valid: bad YAML, an unknown key, or a
187    /// value of the wrong type. Never silently ignored — a config that does not
188    /// parse is a config whose settings are not being applied.
189    #[error("could not parse config file `{path}`")]
190    ConfigParse {
191        path: PathBuf,
192        #[source]
193        source: serde_yaml::Error,
194    },
195
196    /// The working directory could not be read, so the walk-up looking for a
197    /// project config has nowhere to start.
198    #[error("could not determine the current directory")]
199    CurrentDir(#[source] std::io::Error),
200
201    /// An environment file was found but could not be read. Its own variant for
202    /// the same reason [`ConfigIo`](Self::ConfigIo) is: the user did not name
203    /// this path on the command line, so the error has to say which file to go
204    /// and fix.
205    #[error("could not read environment file `{path}`")]
206    EnvIo {
207        path: PathBuf,
208        #[source]
209        source: std::io::Error,
210    },
211
212    /// An environment file was read but is not a flat map of string to string:
213    /// bad YAML, a nested mapping, or a value that is not a string. Never
214    /// ignored — an environment that does not parse is a set of variables that
215    /// are not being substituted.
216    #[error("could not parse environment file `{path}`")]
217    EnvParse {
218        path: PathBuf,
219        #[source]
220        source: serde_yaml::Error,
221    },
222
223    /// An environment could not be written back to disk after an edit —
224    /// [`Environment::save_to_path`](crate::Environment::save_to_path). The
225    /// save half of [`EnvIo`](Self::EnvIo), kept separate for the same reason
226    /// [`SaveIo`](Self::SaveIo) is split from [`Io`](Self::Io): the message
227    /// has to say "write" rather than "read", and a write failure here means
228    /// the edit was never persisted — the file at `path` is left exactly as
229    /// it was before the save was attempted.
230    #[error("could not write environment file `{path}`")]
231    EnvSaveIo {
232        path: PathBuf,
233        #[source]
234        source: std::io::Error,
235    },
236
237    /// An environment file parsed as valid YAML, but its `auth:` block broke
238    /// a rule `serde` cannot express: exactly one of `bearer`/`basic`/
239    /// `api_key` may be set — the same rule
240    /// [`Request::validate`](crate::Request::validate) enforces for a
241    /// request's own `auth:` block, reused here since an environment's
242    /// `auth:` is the exact same [`Auth`](crate::Auth) shape. Raised at
243    /// parse time, from [`Environment::from_yaml_str`](crate::Environment::from_yaml_str)/
244    /// [`from_path`](crate::Environment::from_path) — a collision between an
245    /// environment's default `auth:` and a request's own header/query is a
246    /// different failure, folded into [`InvalidRequest`](Self::InvalidRequest)
247    /// instead, since it can only be discovered once a specific request is
248    /// being substituted against this environment.
249    #[error("invalid environment ({}): {reason}", describe_environment(.path))]
250    InvalidEnvironment {
251        path: Option<PathBuf>,
252        reason: String,
253    },
254
255    /// A request referenced `{{name}}` and the active environment has no such
256    /// variable.
257    ///
258    /// Carries the names that *are* defined, and the file they came from, the
259    /// way [`RequestNotFound`](Self::RequestNotFound) carries the request names
260    /// a collection does have. Raised while the request is being built, so it
261    /// happens before anything goes over the wire.
262    #[error(
263        "no variable named `{name}` in {}{}",
264        describe_variables(.environment, .available),
265        describe_captured(.captured)
266    )]
267    VariableNotFound {
268        name: String,
269        available: Vec<String>,
270        /// The environment file the variable was looked for in, or `None` when
271        /// no environment file was found at all.
272        environment: Option<PathBuf>,
273        /// The names captured by earlier requests in this run, which are looked
274        /// up alongside the file's own and so belong in the same message.
275        ///
276        /// Listed separately from `available` rather than merged into it
277        /// because they did not come from the file the message names, and a
278        /// list that claimed they did would send the reader to edit a file that
279        /// has never mentioned them. Empty for a single request, and for every
280        /// run of a collection that captures nothing — in which case the
281        /// message is exactly the one it has always been.
282        captured: Vec<String>,
283    },
284
285    /// An environment file value is `${VAR}` and `VAR` is not in the OS
286    /// environment.
287    ///
288    /// Deliberately an error rather than an empty string: silently sending
289    /// `Authorization: Bearer ` would turn a missing secret into a puzzling 401
290    /// instead of a message naming the variable to export.
291    #[error(
292        "environment variable `{name}` is not set (referenced by `{variable}` in {})",
293        describe_environment(.environment)
294    )]
295    EnvVarNotSet {
296        /// The OS environment variable that is not set.
297        name: String,
298        /// The environment-file variable whose value referenced it.
299        variable: String,
300        environment: Option<PathBuf>,
301    },
302
303    /// A `pre_request` or `post_request` script does not parse.
304    ///
305    /// Its own variant, separate from [`ScriptFailed`](Self::ScriptFailed),
306    /// because they are different problems for a user to fix — the same reason
307    /// config and environment each split IO from Parse. A script that does not
308    /// compile is a broken *file*: nothing about the request or the response
309    /// could have changed the outcome, and the fix is a syntax error at a
310    /// position Rhai names. Both hooks are compiled before the request is sent,
311    /// so this is always raised with nothing having gone over the wire.
312    #[error("could not compile the `{hook}` script")]
313    ScriptParse {
314        hook: script::Hook,
315        #[source]
316        source: rhai::ParseError,
317    },
318
319    /// A script compiled, ran, and threw — or hit a runtime error.
320    ///
321    /// Only ever produced for `pre_request`. A `post_request` script that fails
322    /// is a statement about a response that did arrive, so it comes back as
323    /// [`ScriptOutcome::Failed`](crate::script::ScriptOutcome::Failed) rather than as
324    /// an error; see the note on that type.
325    #[error("the `{hook}` script failed: {message}")]
326    ScriptFailed { hook: script::Hook, message: String },
327
328    /// A `pre_request` script ran without throwing but left `request` in a
329    /// state that is not a request: an unknown field, a value of the wrong
330    /// type, or an assignment to the read-only `method`.
331    ///
332    /// Separate from [`ScriptFailed`](Self::ScriptFailed) because the script
333    /// did not fail — it succeeded at doing something Sendra cannot act on, and
334    /// the fix is a line of the script rather than whatever it was checking.
335    #[error("the `pre_request` script left the request in a state it cannot be sent in: {reason}")]
336    ScriptRequest { reason: String },
337
338    /// An `auth.oauth` token acquisition failed: bad credentials, an
339    /// unreachable or non-2xx token endpoint, or a response with no
340    /// `access_token`.
341    ///
342    /// Raised lazily, only when a request whose `auth.oauth` needs a token is
343    /// about to run — [`Request::resolve_oauth`](crate::Request::resolve_oauth),
344    /// called just before [`Request::resolve_auth`](crate::Request::resolve_auth)
345    /// — rather than up front for the whole run, since it happens per-request
346    /// the same way a substitution failure does. A request whose acquisition
347    /// fails is a per-request failure with no response, the same category
348    /// `VariableNotFound` already is; the siblings around it, using other
349    /// auth or none at all, are unaffected. See [`crate::oauth`] for the
350    /// in-run cache this reads and writes, and for why a failure for a given
351    /// `oauth:` config is remembered rather than retried for every later
352    /// request that shares it.
353    #[error("could not acquire an OAuth token from `{token_url}`: {reason}")]
354    OAuthAcquisition { token_url: String, reason: String },
355
356    /// Could not build an `authorization_code` login's authorization URL:
357    /// `auth.oauth.authorization_url` did not parse as a URL. Raised by
358    /// [`crate::oauth::build_authorization_url`], the one step of the
359    /// interactive login flow that runs before any browser or network call —
360    /// see [`crate::oauth`]'s module docs.
361    #[error("could not build an authorization URL from `{authorization_url}`: {reason}")]
362    OAuthAuthorizationUrl {
363        authorization_url: String,
364        reason: String,
365    },
366}