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}