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}