Skip to main content

tollgate_server/
error.rs

1//! RFC-7807 `application/problem+json` errors with stable machine codes.
2//!
3//! The `code` strings are wire contract: `tollgate-client`'s HTTP transport maps
4//! them back to `AllocateError` variants. Change one and the loopback
5//! correctness suite fails.
6
7use axum::extract::rejection::{JsonRejection, PathRejection};
8use axum::extract::{FromRequest, FromRequestParts, Json, MatchedPath, Path, Request};
9use axum::http::StatusCode;
10use axum::http::request::Parts;
11use axum::middleware::Next;
12use axum::response::{IntoResponse, Response};
13use serde::de::DeserializeOwned;
14
15use tollgate_core::{Generation, SnapshotValidationError};
16use tollgate_store::wire::Problem;
17use tollgate_store::{
18    AllocateError, CreateAccountError, PublishSnapshotError, SetStatusError, StoreError,
19};
20use tollgate_store::{IngestError, MAX_INGEST_BATCH};
21
22#[derive(Debug)]
23pub struct ApiError {
24    pub status: StatusCode,
25    pub code: &'static str,
26    pub title: String,
27    pub generation: Option<Generation>,
28    pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
29    pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
30}
31
32/// JSON input whose extractor failures stay inside the RFC-7807 contract.
33///
34/// Axum's default rejection is plain text. Keeping the wrapper at the service
35/// boundary makes malformed identifiers and every same-pattern body failure
36/// structured without relying on each handler to remember an error mapping.
37pub(crate) struct ApiJson<T>(pub T);
38
39impl<T, S> FromRequest<S> for ApiJson<T>
40where
41    T: DeserializeOwned,
42    S: Send + Sync,
43{
44    type Rejection = ApiError;
45
46    async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
47        Json::<T>::from_request(request, state)
48            .await
49            .map(|Json(value)| Self(value))
50            .map_err(ApiError::from)
51    }
52}
53
54/// Path input whose parse failures stay distinct from a legitimate 404.
55pub(crate) struct ApiPath<T>(pub T);
56
57/// Query decoding is subject to the same structured external-input contract.
58pub(crate) struct ApiQuery<T>(pub T);
59impl<T, S> FromRequestParts<S> for ApiQuery<T>
60where
61    T: DeserializeOwned + Send,
62    S: Send + Sync,
63{
64    type Rejection = ApiError;
65    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
66        axum::extract::Query::<T>::from_request_parts(parts, state)
67            .await
68            .map(|axum::extract::Query(value)| Self(value))
69            .map_err(|_| {
70                ApiError::bad_request(
71                    "invalid-query",
72                    "query parameters are malformed or unsupported",
73                )
74            })
75    }
76}
77
78impl<T, S> FromRequestParts<S> for ApiPath<T>
79where
80    T: DeserializeOwned + Send,
81    S: Send + Sync,
82{
83    type Rejection = ApiError;
84
85    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
86        Path::<T>::from_request_parts(parts, state)
87            .await
88            .map(|Path(value)| Self(value))
89            .map_err(ApiError::from)
90    }
91}
92
93impl ApiError {
94    pub fn unauthorized() -> Self {
95        Self {
96            status: StatusCode::UNAUTHORIZED,
97            code: "authentication-required",
98            title: "valid control-plane credentials required".into(),
99            generation: None,
100            balance_exhaustion: None,
101            balance_shortfall: None,
102        }
103    }
104
105    pub fn forbidden() -> Self {
106        Self {
107            status: StatusCode::FORBIDDEN,
108            code: "scope-forbidden",
109            title: "credential does not authorize this control-plane operation".into(),
110            generation: None,
111            balance_exhaustion: None,
112            balance_shortfall: None,
113        }
114    }
115    pub fn not_found(code: &'static str, title: impl Into<String>) -> Self {
116        ApiError {
117            status: StatusCode::NOT_FOUND,
118            code,
119            title: title.into(),
120            generation: None,
121            balance_exhaustion: None,
122            balance_shortfall: None,
123        }
124    }
125
126    pub fn revoked(generation: Generation) -> Self {
127        ApiError {
128            status: StatusCode::GONE,
129            code: "revoked-principal",
130            title: "snapshot revoked".to_string(),
131            generation: Some(generation),
132            balance_exhaustion: None,
133            balance_shortfall: None,
134        }
135    }
136
137    pub fn bad_request(code: &'static str, title: impl Into<String>) -> Self {
138        ApiError {
139            status: StatusCode::BAD_REQUEST,
140            code,
141            title: title.into(),
142            generation: None,
143            balance_exhaustion: None,
144            balance_shortfall: None,
145        }
146    }
147
148    /// The backend cannot answer this at all, as opposed to answering
149    /// "nothing" — a distinction a caller must be able to act on differently
150    /// (GL-48).
151    pub fn not_implemented(code: &'static str, title: impl Into<String>) -> Self {
152        ApiError {
153            status: StatusCode::NOT_IMPLEMENTED,
154            code,
155            title: title.into(),
156            generation: None,
157            balance_exhaustion: None,
158            balance_shortfall: None,
159        }
160    }
161}
162
163impl From<JsonRejection> for ApiError {
164    fn from(error: JsonRejection) -> Self {
165        let status = error.into_response().status();
166        // A body over the endpoint's limit is not malformed JSON, and saying
167        // so sent a client looking for a syntax error in a payload it had
168        // serialised correctly. The two are told apart by status rather than
169        // by matching axum's rejection variants, because the nesting that
170        // produces a 413 is an internal detail of the extractor and the status
171        // is the part of that behaviour axum documents (GL-61).
172        //
173        // Distinct codes matter beyond the message: a client can retry a
174        // transient failure, and must never retry this one unchanged — an
175        // oversized batch is refused identically forever.
176        if status == StatusCode::PAYLOAD_TOO_LARGE {
177            return ApiError {
178                status,
179                code: "batch-too-large",
180                title: format!(
181                    "request body exceeds this endpoint's limit; \
182                     usage batches are capped at {MAX_INGEST_BATCH} events"
183                ),
184                generation: None,
185                balance_exhaustion: None,
186                balance_shortfall: None,
187            };
188        }
189        ApiError {
190            status,
191            code: "invalid-json",
192            title: "request body is not valid JSON for this endpoint".to_string(),
193            generation: None,
194            balance_exhaustion: None,
195            balance_shortfall: None,
196        }
197    }
198}
199
200impl From<PathRejection> for ApiError {
201    fn from(error: PathRejection) -> Self {
202        let status = error.into_response().status();
203        ApiError {
204            status,
205            code: "invalid-id",
206            title: "path identifier must be exactly 32 lowercase hexadecimal digits".to_string(),
207            generation: None,
208            balance_exhaustion: None,
209            balance_shortfall: None,
210        }
211    }
212}
213
214impl From<AllocateError> for ApiError {
215    fn from(e: AllocateError) -> Self {
216        let balance_exhaustion = match &e {
217            AllocateError::BalanceExhausted(evidence) => Some(*evidence),
218            _ => None,
219        };
220        let balance_shortfall = match &e {
221            AllocateError::BalanceInsufficient(evidence) => Some(*evidence),
222            _ => None,
223        };
224        let (status, code) = match e {
225            AllocateError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
226            AllocateError::AccountInactive => (StatusCode::CONFLICT, "account-inactive"),
227            // Attested and unattested shortfalls share the code, so a client
228            // that predates the extension reads the refusal it always did.
229            AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
230                (StatusCode::CONFLICT, "insufficient-balance")
231            }
232            AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
233            AllocateError::InvalidTtl => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-ttl"),
234            AllocateError::UnknownLease => (StatusCode::NOT_FOUND, "unknown-lease"),
235            AllocateError::Fenced => (StatusCode::CONFLICT, "fenced"),
236            AllocateError::LeaseNotActive => (StatusCode::CONFLICT, "lease-not-active"),
237            AllocateError::InvalidRelease => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-release"),
238            AllocateError::Storage(inner) => return ApiError::from(inner),
239        };
240        ApiError {
241            status,
242            code,
243            title: e.to_string(),
244            generation: None,
245            balance_exhaustion,
246            balance_shortfall,
247        }
248    }
249}
250
251impl From<CreateAccountError> for ApiError {
252    fn from(e: CreateAccountError) -> Self {
253        match e {
254            CreateAccountError::AlreadyExists => ApiError {
255                status: StatusCode::CONFLICT,
256                code: "account-exists",
257                title: "account already exists".to_string(),
258                generation: None,
259                balance_exhaustion: None,
260                balance_shortfall: None,
261            },
262            CreateAccountError::Storage(inner) => ApiError::from(inner),
263        }
264    }
265}
266
267impl From<tollgate_store::KeyError> for ApiError {
268    fn from(e: tollgate_store::KeyError) -> Self {
269        use tollgate_store::KeyError;
270        let (status, code) = match &e {
271            KeyError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
272            KeyError::UnknownKey => (StatusCode::NOT_FOUND, "unknown-credential"),
273            // 409, not 422: the request is well-formed and the caller is not
274            // at fault for asking. It is also the retry answer — a caller that
275            // lost the response and resent the same `key_id` is being told its
276            // first call worked, which is the truth and discloses nothing.
277            KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
278            // 409 for the same reason: nothing about the request is malformed.
279            // The account is at the bound it was asked to respect, and the
280            // remedy is to revoke a credential, not to rephrase the call.
281            KeyError::ActiveKeyLimit { .. } => (StatusCode::CONFLICT, "active-key-limit"),
282            KeyError::Storage(inner) => return inner.clone().into(),
283        };
284        ApiError {
285            status,
286            code,
287            title: e.to_string(),
288            generation: None,
289            balance_exhaustion: None,
290            balance_shortfall: None,
291        }
292    }
293}
294
295impl From<tollgate_store::KeySnapshotError> for ApiError {
296    fn from(e: tollgate_store::KeySnapshotError) -> Self {
297        use tollgate_store::KeySnapshotError;
298        let (status, code) = match &e {
299            // The answer revocation gives for a foreign or unknown key.
300            KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
301            // 409: well-formed, but the credential is terminally retired and is
302            // never granted positive authorization again (INVARIANTS.md GL-27).
303            KeySnapshotError::Retired { .. } => (StatusCode::CONFLICT, "credential-retired"),
304            KeySnapshotError::Publish(inner) => return inner.clone().into(),
305            KeySnapshotError::Storage(inner) => return inner.clone().into(),
306        };
307        ApiError {
308            status,
309            code,
310            title: e.to_string(),
311            generation: None,
312            balance_exhaustion: None,
313            balance_shortfall: None,
314        }
315    }
316}
317
318impl From<tollgate_store::BudgetError> for ApiError {
319    fn from(e: tollgate_store::BudgetError) -> Self {
320        match e {
321            tollgate_store::BudgetError::UnknownAccount => ApiError {
322                status: StatusCode::NOT_FOUND,
323                code: "unknown-account",
324                title: e.to_string(),
325                generation: None,
326                balance_exhaustion: None,
327                balance_shortfall: None,
328            },
329            tollgate_store::BudgetError::Storage(inner) => inner.into(),
330        }
331    }
332}
333
334impl From<SetStatusError> for ApiError {
335    fn from(e: SetStatusError) -> Self {
336        match e {
337            SetStatusError::UnknownAccount => ApiError {
338                status: StatusCode::NOT_FOUND,
339                code: "unknown-account",
340                title: e.to_string(),
341                generation: None,
342                balance_exhaustion: None,
343                balance_shortfall: None,
344            },
345            // 409, not 422: the request is well-formed and the operator is
346            // not at fault for asking. The account is simply in a state no
347            // transition leaves (INVARIANTS.md GL-22).
348            SetStatusError::AccountClosed => ApiError {
349                status: StatusCode::CONFLICT,
350                code: "account-closed",
351                title: e.to_string(),
352                generation: None,
353                balance_exhaustion: None,
354                balance_shortfall: None,
355            },
356            SetStatusError::Storage(inner) => ApiError::from(inner),
357        }
358    }
359}
360
361impl From<PublishSnapshotError> for ApiError {
362    fn from(e: PublishSnapshotError) -> Self {
363        match e {
364            PublishSnapshotError::CredentialMismatch { .. } => ApiError {
365                status: StatusCode::UNPROCESSABLE_ENTITY,
366                code: "invalid-credential-binding",
367                title: e.to_string(),
368                generation: None,
369                balance_exhaustion: None,
370                balance_shortfall: None,
371            },
372            PublishSnapshotError::StatusMismatch { .. } => ApiError {
373                status: StatusCode::CONFLICT,
374                code: "snapshot-status-mismatch",
375                title: e.to_string(),
376                generation: None,
377                balance_exhaustion: None,
378                balance_shortfall: None,
379            },
380            // 409 for the reason the status mismatch is: the request is
381            // well-formed and the operator is not at fault — the account
382            // simply owns this fact, and it is changed through its own
383            // endpoint (GL-99).
384            PublishSnapshotError::CapacityClassMismatch { .. } => ApiError {
385                status: StatusCode::CONFLICT,
386                code: "snapshot-capacity-class-mismatch",
387                title: e.to_string(),
388                generation: None,
389                balance_exhaustion: None,
390                balance_shortfall: None,
391            },
392            PublishSnapshotError::Storage(inner) => ApiError::from(inner),
393        }
394    }
395}
396
397impl From<StoreError> for ApiError {
398    fn from(_: StoreError) -> Self {
399        ApiError {
400            status: StatusCode::SERVICE_UNAVAILABLE,
401            code: "storage",
402            // A backend owns arbitrary text, which may include credentials or
403            // private row values. Never format it into a public diagnostic.
404            title: "backend unavailable".into(),
405            generation: None,
406            balance_exhaustion: None,
407            balance_shortfall: None,
408        }
409    }
410}
411
412impl From<IngestError> for ApiError {
413    fn from(error: IngestError) -> Self {
414        match error {
415            // The store could not answer. A client should retry, and 503 is
416            // the status that says so.
417            IngestError::Unavailable(e) => ApiError::from(e),
418            // The store examined this batch and refused it: an accounting
419            // total that cannot absorb these units will not absorb them on a
420            // replay either. 422 rather than 503, so a client can tell a
421            // refusal it must not repeat from an outage it should wait out —
422            // which is the distinction GL-61 is about, made at both ends of the
423            // wire rather than only at the transport.
424            IngestError::Refused(_) => ApiError {
425                status: StatusCode::UNPROCESSABLE_ENTITY,
426                code: "usage-refused",
427                title: "usage batch refused".into(),
428                generation: None,
429                balance_exhaustion: None,
430                balance_shortfall: None,
431            },
432        }
433    }
434}
435
436impl From<SnapshotValidationError> for ApiError {
437    fn from(error: SnapshotValidationError) -> Self {
438        ApiError {
439            status: StatusCode::UNPROCESSABLE_ENTITY,
440            code: "invalid-snapshot-limits",
441            title: error.to_string(),
442            generation: None,
443            balance_exhaustion: None,
444            balance_shortfall: None,
445        }
446    }
447}
448
449impl IntoResponse for ApiError {
450    fn into_response(self) -> Response {
451        self.render(|| diagnostic_id(getrandom::fill))
452    }
453}
454
455/// Only generated identifiers and static machine codes cross into diagnostics.
456/// The middleware receives this marker, never a backend error or response text.
457#[derive(Clone)]
458struct HttpFailure {
459    code: &'static str,
460    error_id: Option<String>,
461}
462
463fn diagnostic_id(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Option<String> {
464    let mut bytes = [0u8; 16];
465    fill(&mut bytes).ok()?;
466    Some(tollgate_core::RequestId(u128::from_be_bytes(bytes)).to_string())
467}
468
469impl ApiError {
470    fn render(self, new_id: impl FnOnce() -> Option<String>) -> Response {
471        let unauthorized = self.status == StatusCode::UNAUTHORIZED;
472        let failure =
473            (self.status.is_server_error() || self.code == "usage-refused").then(|| HttpFailure {
474                code: self.code,
475                error_id: new_id(),
476            });
477        let problem = Problem {
478            status: self.status.as_u16(),
479            code: self.code.to_string(),
480            title: self.title,
481            generation: self.generation,
482            balance_exhaustion: self.balance_exhaustion,
483            balance_shortfall: self.balance_shortfall,
484        };
485        // Keep the public Problem Rust shape intact. This optional JSON
486        // extension is ignored by existing clients and carries no authority.
487        #[derive(serde::Serialize)]
488        struct DiagnosticProblem {
489            #[serde(flatten)]
490            problem: Problem,
491            #[serde(skip_serializing_if = "Option::is_none")]
492            error_id: Option<String>,
493        }
494        let body = DiagnosticProblem {
495            problem,
496            error_id: failure
497                .as_ref()
498                .and_then(|failure| failure.error_id.clone()),
499        };
500        let mut response = (self.status, Json(body)).into_response();
501        if let Some(failure) = failure {
502            response.extensions_mut().insert(failure);
503        }
504        response.headers_mut().insert(
505            axum::http::header::CONTENT_TYPE,
506            axum::http::HeaderValue::from_static("application/problem+json"),
507        );
508        if unauthorized {
509            response.headers_mut().insert(
510                axum::http::header::WWW_AUTHENTICATE,
511                axum::http::HeaderValue::from_static("Bearer realm=\"tollgate-control\""),
512            );
513        }
514        response
515    }
516}
517
518/// The router owns failure reporting, including static route context. Raw
519/// paths, queries, headers, request bodies and backend text are never logged.
520pub(crate) async fn report_http_failure(request: Request, next: Next) -> Response {
521    let route = request.extensions().get::<MatchedPath>().cloned();
522    let response = next.run(request).await;
523    if let Some(failure) = response.extensions().get::<HttpFailure>() {
524        tracing::warn!(
525            target: "tollgate::diagnostics",
526            route = route.as_ref().map(MatchedPath::as_str).unwrap_or("unmatched"),
527            code = failure.code,
528            status = response.status().as_u16(),
529            error_id = failure.error_id.as_deref(),
530            error_id_unavailable = failure.error_id.is_none(),
531            "control-plane operation failed; consult backend health and retained operational records"
532        );
533    }
534    response
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn diagnostic_identifiers_use_all_entropy_and_surface_entropy_failure() {
543        let id = diagnostic_id(|bytes| {
544            bytes.copy_from_slice(&0x00112233445566778899aabbccddeeff_u128.to_be_bytes());
545            Ok(())
546        });
547        assert_eq!(id.as_deref(), Some("00112233445566778899aabbccddeeff"));
548        assert!(diagnostic_id(|_| Err(getrandom::Error::UNSUPPORTED)).is_none());
549    }
550
551    #[tokio::test]
552    async fn entropy_failure_preserves_the_error_without_inventing_an_identifier() {
553        use http_body_util::BodyExt;
554        let response = ApiError::from(StoreError("fixture-secret-70".into())).render(|| None);
555        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
556        let failure = response.extensions().get::<HttpFailure>().unwrap();
557        assert_eq!(failure.code, "storage");
558        assert!(failure.error_id.is_none());
559        let body = response.into_body().collect().await.unwrap().to_bytes();
560        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
561        assert_eq!(json["title"], "backend unavailable");
562        assert!(json.get("error_id").is_none());
563    }
564}