Skip to main content

tapes_client/
error.rs

1//! One taxonomy for everything that can go wrong between a named operation and
2//! a decoded response.
3//!
4//! # Why one enum and not one per surface
5//!
6//! The sealed contract surface and the discovered cassette surface used to
7//! carry an error type each, and the two disagreed in ways nothing checked: a
8//! non-success status was a rich variant on one side and absent from the other,
9//! a URL failure had two spellings, and "could not decode" meant *the bytes are
10//! not JSON* in one crate and *the JSON is not the requested type* in the
11//! other. A consumer wrapping both got two vocabularies for one API and had to
12//! decide, per variant, whether the difference was meaningful. It never was.
13//!
14//! The variants below are grouped by the four things that actually happen:
15//!
16//! - **Contract** — a refusal. The operation, parameter, or body a caller named
17//!   disagrees with the document, and nothing is sent. These are build defects
18//!   at the call site, which is why they are loud and name the offender.
19//! - **Transport** — the request could not be delivered or the client could not
20//!   be built. Carries a [`TransportError`], which is deliberately opaque: the
21//!   seam admits implementations that have never heard of HTTP.
22//! - **ApiStatus** — the request arrived and the server refused it. The body
23//!   travels with the status because every tapes error body names the offending
24//!   parameter, and the bare status never does.
25//! - **Decode** — the bytes came back and are not what was asked for.
26//!
27//! This enum is `#[non_exhaustive]`: it is the shared vocabulary of a growing
28//! surface, and a consumer that matches it must say what it does with a
29//! condition its build predates rather than fail to compile when one appears.
30
31use snafu::Snafu;
32
33use crate::transport::TransportError;
34
35/// This crate's result alias.
36pub type Result<T, E = Error> = std::result::Result<T, E>;
37
38/// Anything that can go wrong driving a tapes API call.
39///
40/// `#[snafu(module)]` puts the generated context selectors in a nested `error`
41/// module rather than at this module's root: the selectors are construction
42/// detail, and a consumer matches on the variants.
43#[derive(Debug, Snafu)]
44#[snafu(module, visibility(pub(crate)))]
45#[non_exhaustive]
46pub enum Error {
47    // ---- Contract refusals: nothing was sent ----
48    /// The contract embedded in this build did not parse, or reduced to
49    /// nothing. Only reachable from a build whose vendored document is corrupt
50    /// — this crate's own tests fail before such a build ships.
51    #[snafu(display("the vendored {surface} contract embedded in this build did not parse"))]
52    VendoredContract {
53        /// Which contract failed.
54        surface: &'static str,
55    },
56
57    /// A caller named an operation the contract does not have. A build defect
58    /// wherever the coverage gate runs, which is the point of the gate.
59    #[snafu(display("the vendored tapes-api contract has no operation {operation:?}"))]
60    ContractOperation {
61        /// The operation id that failed to resolve.
62        operation: String,
63    },
64
65    /// A caller tried to send a parameter the contract does not declare on
66    /// that operation. Refused rather than sent: an undeclared parameter is
67    /// exactly the drift a vendored contract exists to catch, and a server
68    /// that ignores an unknown query parameter would hide it.
69    #[snafu(display(
70        "the vendored tapes-api contract does not declare parameter {parameter:?} on {operation:?}"
71    ))]
72    ContractParameter {
73        /// The operation being called.
74        operation: String,
75        /// The undeclared wire name.
76        parameter: String,
77    },
78
79    /// A caller had no value for a path parameter the operation requires, so
80    /// no URL can be built — the substitution would leave a literal `{id}`
81    /// segment addressing nothing.
82    #[snafu(display(
83        "operation {operation:?} requires path parameter {parameter:?} and none was supplied"
84    ))]
85    ContractPathParameter {
86        /// The operation being called.
87        operation: String,
88        /// The missing path parameter.
89        parameter: String,
90    },
91
92    /// A caller had no value for a query or header parameter the contract
93    /// marks required.
94    ///
95    /// A missing path parameter cannot produce a URL at all, so it was always
96    /// refused; a missing required query parameter produces a URL that is
97    /// perfectly well-formed and still not a request the contract describes.
98    /// The server answers it with a 400 whose wording is its own, which is a
99    /// worse error later instead of a precise one now — and on an operation
100    /// whose filter is what scopes the response, a client that guessed wrong
101    /// about requiredness would be asking a different question than it thinks.
102    #[snafu(display(
103        "operation {operation:?} requires {location} parameter {parameter:?} and none was supplied"
104    ))]
105    ContractRequiredParameter {
106        /// The operation being called.
107        operation: String,
108        /// The missing parameter's wire name.
109        parameter: String,
110        /// Where the contract declared it: `query` or `header`.
111        location: &'static str,
112    },
113
114    /// A caller's request body disagrees with what the operation declares.
115    ///
116    /// Both directions are refusals, and the reason is the same: a body-shaped
117    /// mismatch is invisible on the wire. An operation whose `requestBody` is
118    /// required, called without one, reaches the server as a syntactically
119    /// fine request that means nothing; a body sent to an operation that
120    /// declares none is dropped by whatever is in front of the handler. Either
121    /// way the call site looks correct.
122    #[snafu(display("operation {operation:?} {detail}"))]
123    ContractBody {
124        /// The operation being called.
125        operation: String,
126        /// What is wrong, phrased to complete the sentence.
127        detail: &'static str,
128    },
129
130    /// The server's response shape changed out from under this client.
131    #[snafu(display("unexpected server contract: {detail}"))]
132    Contract {
133        /// What changed.
134        detail: &'static str,
135    },
136
137    /// Discovery named an OpenAPI document somewhere other than on this
138    /// server. Refused rather than followed: `Url::join` treats an absolute
139    /// URL as a replacement, so honouring it would fetch a spec from a host
140    /// the user never named.
141    #[snafu(display("cassette discovery named a non-relative OpenAPI path {path:?}"))]
142    SpecPath {
143        /// What discovery published.
144        path: String,
145    },
146
147    /// A spec described an operation with a verb that is not an HTTP method.
148    #[snafu(display("cassette spec used an unusable HTTP method {method:?}"))]
149    Method {
150        /// The offending verb.
151        method: String,
152    },
153
154    /// A cassette noun parsed but is not on the surface. Only reachable if the
155    /// surface changed between building the parser and dispatching.
156    #[snafu(display("no cassette named {name:?} is served here"))]
157    UnknownCassette {
158        /// The noun that was invoked.
159        name: String,
160    },
161
162    /// A cassette method parsed but is not on the cassette.
163    #[snafu(display("cassette {cassette:?} has no method {method:?}"))]
164    UnknownMethod {
165        /// The cassette that was invoked.
166        cassette: String,
167        /// The method that was invoked.
168        method: String,
169    },
170
171    // ---- URL construction ----
172    /// A URL could not be built from the base and the contract's path.
173    #[snafu(display("could not build the request URL"))]
174    Url {
175        /// Underlying parse failure.
176        source: url::ParseError,
177    },
178
179    /// The base URL cannot carry a path (`mailto:`, `data:`), so no route can
180    /// be joined onto it.
181    #[snafu(display("the base URL cannot be a base for API paths"))]
182    NotABase,
183
184    // ---- Transport ----
185    /// The request could not be delivered.
186    ///
187    /// The source is opaque on purpose. A transport may be an HTTP client, a
188    /// local socket carrying opaque frames, or a test double, and this layer
189    /// has no business naming any of their error types — that is precisely the
190    /// coupling the seam exists to prevent.
191    /// The source is rendered inline because a transport's refusal is often the
192    /// whole diagnosis — "the server answered with a redirect" is actionable,
193    /// "could not reach the tapes API" alone is not — and a consumer that
194    /// prints only the top-level error would otherwise lose it.
195    #[snafu(display("could not reach the tapes API: {source}"))]
196    Transport {
197        /// Underlying transport failure.
198        source: TransportError,
199    },
200
201    /// The transport itself could not be constructed. Requests error out
202    /// rather than fall back to a client with different (redirect-following)
203    /// behavior.
204    #[snafu(display("could not initialize the HTTP client"))]
205    ClientInit,
206
207    // ---- The server answered, and said no ----
208    /// The server answered with a non-success status. The body is carried
209    /// because every tapes error body names the offending parameter.
210    #[snafu(display("tapes API returned {status} for {endpoint}: {body}"))]
211    ApiStatus {
212        /// HTTP status returned.
213        status: u16,
214        /// Endpoint that was called.
215        endpoint: String,
216        /// Response body, verbatim.
217        body: String,
218    },
219
220    // ---- Decoding ----
221    /// The response could not be decoded: it is not JSON, or it is JSON that
222    /// is not the type the caller asked for.
223    ///
224    /// The second half is unreachable for the untyped instantiation — every
225    /// JSON document is a [`serde_json::Value`] — so a decode failure on a
226    /// caller-chosen model is visible exactly where that choice was made.
227    #[snafu(display("could not decode the tapes API response"))]
228    Decode {
229        /// Underlying JSON failure.
230        source: serde_json::Error,
231    },
232
233    // ---- Request bodies supplied by a user ----
234    /// `--body @<path>` could not be read.
235    #[snafu(display("could not read the request body at {path}"))]
236    BodyFile {
237        /// Where the read was attempted.
238        path: String,
239        /// Underlying IO failure.
240        source: std::io::Error,
241    },
242
243    /// `--body` was not JSON. Checked before sending so the failure names the
244    /// quoting mistake rather than arriving as a cassette's schema error.
245    #[snafu(display("--body is not valid JSON"))]
246    InvalidBody {
247        /// Underlying JSON failure.
248        source: serde_json::Error,
249    },
250
251    /// The parsed body could not be re-rendered for sending. Only reachable
252    /// if serde_json emits a value it cannot serialize back.
253    #[snafu(display("could not render the request body"))]
254    RenderBody {
255        /// Underlying JSON failure.
256        source: serde_json::Error,
257    },
258}