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 supplied claimed filter pairs on an operation the vendored
80    /// contract does not document as claim-bearing. The claimed channel is a
81    /// scoped bypass, not a general one: the server's claims are per-surface,
82    /// and the sealed document names the surfaces that carry the extension
83    /// (`ops::CLAIM_BEARING_OPS`). Anywhere else, an unknown name is exactly
84    /// the drift the declared-parameter refusal exists to catch — so the call
85    /// is refused before anything is sent.
86    #[snafu(display(
87        "the vendored tapes-api contract documents no claimed filter params on {operation:?}"
88    ))]
89    ContractClaims {
90        /// The operation being called.
91        operation: String,
92    },
93
94    /// A caller had no value for a path parameter the operation requires, so
95    /// no URL can be built — the substitution would leave a literal `{id}`
96    /// segment addressing nothing.
97    #[snafu(display(
98        "operation {operation:?} requires path parameter {parameter:?} and none was supplied"
99    ))]
100    ContractPathParameter {
101        /// The operation being called.
102        operation: String,
103        /// The missing path parameter.
104        parameter: String,
105    },
106
107    /// A caller had no value for a query or header parameter the contract
108    /// marks required.
109    ///
110    /// A missing path parameter cannot produce a URL at all, so it was always
111    /// refused; a missing required query parameter produces a URL that is
112    /// perfectly well-formed and still not a request the contract describes.
113    /// The server answers it with a 400 whose wording is its own, which is a
114    /// worse error later instead of a precise one now — and on an operation
115    /// whose filter is what scopes the response, a client that guessed wrong
116    /// about requiredness would be asking a different question than it thinks.
117    #[snafu(display(
118        "operation {operation:?} requires {location} parameter {parameter:?} and none was supplied"
119    ))]
120    ContractRequiredParameter {
121        /// The operation being called.
122        operation: String,
123        /// The missing parameter's wire name.
124        parameter: String,
125        /// Where the contract declared it: `query` or `header`.
126        location: &'static str,
127    },
128
129    /// A caller's request body disagrees with what the operation declares.
130    ///
131    /// Both directions are refusals, and the reason is the same: a body-shaped
132    /// mismatch is invisible on the wire. An operation whose `requestBody` is
133    /// required, called without one, reaches the server as a syntactically
134    /// fine request that means nothing; a body sent to an operation that
135    /// declares none is dropped by whatever is in front of the handler. Either
136    /// way the call site looks correct.
137    #[snafu(display("operation {operation:?} {detail}"))]
138    ContractBody {
139        /// The operation being called.
140        operation: String,
141        /// What is wrong, phrased to complete the sentence.
142        detail: &'static str,
143    },
144
145    /// The server's response shape changed out from under this client.
146    #[snafu(display("unexpected server contract: {detail}"))]
147    Contract {
148        /// What changed.
149        detail: &'static str,
150    },
151
152    /// Discovery named an OpenAPI document somewhere other than on this
153    /// server. Refused rather than followed: `Url::join` treats an absolute
154    /// URL as a replacement, so honouring it would fetch a spec from a host
155    /// the user never named.
156    #[snafu(display("cassette discovery named a non-relative OpenAPI path {path:?}"))]
157    SpecPath {
158        /// What discovery published.
159        path: String,
160    },
161
162    /// A spec described an operation with a verb that is not an HTTP method.
163    #[snafu(display("cassette spec used an unusable HTTP method {method:?}"))]
164    Method {
165        /// The offending verb.
166        method: String,
167    },
168
169    /// A cassette noun parsed but is not on the surface. Only reachable if the
170    /// surface changed between building the parser and dispatching.
171    #[snafu(display("no cassette named {name:?} is served here"))]
172    UnknownCassette {
173        /// The noun that was invoked.
174        name: String,
175    },
176
177    /// A cassette method parsed but is not on the cassette.
178    #[snafu(display("cassette {cassette:?} has no method {method:?}"))]
179    UnknownMethod {
180        /// The cassette that was invoked.
181        cassette: String,
182        /// The method that was invoked.
183        method: String,
184    },
185
186    // ---- URL construction ----
187    /// A URL could not be built from the base and the contract's path.
188    #[snafu(display("could not build the request URL"))]
189    Url {
190        /// Underlying parse failure.
191        source: url::ParseError,
192    },
193
194    /// The base URL cannot carry a path (`mailto:`, `data:`), so no route can
195    /// be joined onto it.
196    #[snafu(display("the base URL cannot be a base for API paths"))]
197    NotABase,
198
199    // ---- Transport ----
200    /// The request could not be delivered.
201    ///
202    /// The source is opaque on purpose. A transport may be an HTTP client, a
203    /// local socket carrying opaque frames, or a test double, and this layer
204    /// has no business naming any of their error types — that is precisely the
205    /// coupling the seam exists to prevent.
206    /// The source is rendered inline because a transport's refusal is often the
207    /// whole diagnosis — "the server answered with a redirect" is actionable,
208    /// "could not reach the tapes API" alone is not — and a consumer that
209    /// prints only the top-level error would otherwise lose it.
210    #[snafu(display("could not reach the tapes API: {source}"))]
211    Transport {
212        /// Underlying transport failure.
213        source: TransportError,
214    },
215
216    /// The transport itself could not be constructed. Requests error out
217    /// rather than fall back to a client with different (redirect-following)
218    /// behavior.
219    #[snafu(display("could not initialize the HTTP client"))]
220    ClientInit,
221
222    // ---- The server answered, and said no ----
223    /// The server answered with a non-success status. The body is carried
224    /// because every tapes error body names the offending parameter.
225    #[snafu(display("tapes API returned {status} for {endpoint}: {body}"))]
226    ApiStatus {
227        /// HTTP status returned.
228        status: u16,
229        /// Endpoint that was called.
230        endpoint: String,
231        /// Response body, verbatim.
232        body: String,
233    },
234
235    // ---- Decoding ----
236    /// The response could not be decoded: it is not JSON, or it is JSON that
237    /// is not the type the caller asked for.
238    ///
239    /// The second half is unreachable for the untyped instantiation — every
240    /// JSON document is a [`serde_json::Value`] — so a decode failure on a
241    /// caller-chosen model is visible exactly where that choice was made.
242    #[snafu(display("could not decode the tapes API response"))]
243    Decode {
244        /// Underlying JSON failure.
245        source: serde_json::Error,
246    },
247
248    // ---- Request bodies supplied by a user ----
249    /// `--body @<path>` could not be read.
250    #[snafu(display("could not read the request body at {path}"))]
251    BodyFile {
252        /// Where the read was attempted.
253        path: String,
254        /// Underlying IO failure.
255        source: std::io::Error,
256    },
257
258    /// `--body` was not JSON. Checked before sending so the failure names the
259    /// quoting mistake rather than arriving as a cassette's schema error.
260    #[snafu(display("--body is not valid JSON"))]
261    InvalidBody {
262        /// Underlying JSON failure.
263        source: serde_json::Error,
264    },
265
266    /// The parsed body could not be re-rendered for sending. Only reachable
267    /// if serde_json emits a value it cannot serialize back.
268    #[snafu(display("could not render the request body"))]
269    RenderBody {
270        /// Underlying JSON failure.
271        source: serde_json::Error,
272    },
273}