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