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}