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/// 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: format!(
206                    "request body exceeds this endpoint's limit; \
207                     usage batches are capped at {MAX_INGEST_BATCH} events"
208                ),
209                generation: None,
210                balance_exhaustion: None,
211                balance_shortfall: None,
212            };
213        }
214        ApiError {
215            status,
216            code: "invalid-json",
217            title: "request body is not valid JSON for this endpoint".to_string(),
218            generation: None,
219            balance_exhaustion: None,
220            balance_shortfall: None,
221        }
222    }
223}
224
225impl From<PathRejection> for ApiError {
226    fn from(error: PathRejection) -> Self {
227        let status = error.into_response().status();
228        ApiError {
229            status,
230            code: "invalid-id",
231            title: "path identifier must be exactly 32 lowercase hexadecimal digits".to_string(),
232            generation: None,
233            balance_exhaustion: None,
234            balance_shortfall: None,
235        }
236    }
237}
238
239impl From<AllocateError> for ApiError {
240    fn from(e: AllocateError) -> Self {
241        let balance_exhaustion = match &e {
242            AllocateError::BalanceExhausted(evidence) => Some(*evidence),
243            _ => None,
244        };
245        let balance_shortfall = match &e {
246            AllocateError::BalanceInsufficient(evidence) => Some(*evidence),
247            _ => None,
248        };
249        let (status, code) = match e {
250            AllocateError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
251            AllocateError::AccountInactive => (StatusCode::CONFLICT, "account-inactive"),
252            // Attested and unattested shortfalls share the code, so a client
253            // that predates the extension reads the refusal it always did.
254            AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
255                (StatusCode::CONFLICT, "insufficient-balance")
256            }
257            AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
258            AllocateError::BalanceOverflow => {
259                (StatusCode::UNPROCESSABLE_ENTITY, "balance-overflow")
260            }
261            AllocateError::InvalidTtl => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-ttl"),
262            AllocateError::UnknownLease => (StatusCode::NOT_FOUND, "unknown-lease"),
263            AllocateError::Fenced => (StatusCode::CONFLICT, "fenced"),
264            AllocateError::LeaseNotActive => (StatusCode::CONFLICT, "lease-not-active"),
265            AllocateError::InvalidRelease => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-release"),
266            AllocateError::Storage(inner) => return ApiError::from(inner),
267        };
268        ApiError {
269            status,
270            code,
271            title: e.to_string(),
272            generation: None,
273            balance_exhaustion,
274            balance_shortfall,
275        }
276    }
277}
278
279impl From<CreateAccountError> for ApiError {
280    fn from(e: CreateAccountError) -> Self {
281        match e {
282            CreateAccountError::AlreadyExists => ApiError {
283                status: StatusCode::CONFLICT,
284                code: "account-exists",
285                title: "account already exists".to_string(),
286                generation: None,
287                balance_exhaustion: None,
288                balance_shortfall: None,
289            },
290            CreateAccountError::Storage(inner) => ApiError::from(inner),
291        }
292    }
293}
294
295impl From<tollgate_store::KeyError> for ApiError {
296    fn from(e: tollgate_store::KeyError) -> Self {
297        use tollgate_store::KeyError;
298        let (status, code) = match &e {
299            KeyError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
300            KeyError::UnknownKey => (StatusCode::NOT_FOUND, "unknown-credential"),
301            // 409, not 422: the request is well-formed and the caller is not
302            // at fault for asking. It is also the retry answer — a caller that
303            // lost the response and resent the same `key_id` is being told its
304            // first call worked, which is the truth and discloses nothing.
305            KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
306            // 409 for the same reason: nothing about the request is malformed.
307            // The account is at the bound it was asked to respect, and the
308            // remedy is to revoke a credential, not to rephrase the call.
309            KeyError::ActiveKeyLimit { .. } => (StatusCode::CONFLICT, "active-key-limit"),
310            KeyError::Storage(inner) => return inner.clone().into(),
311        };
312        ApiError {
313            status,
314            code,
315            title: e.to_string(),
316            generation: None,
317            balance_exhaustion: None,
318            balance_shortfall: None,
319        }
320    }
321}
322
323impl From<tollgate_store::KeySnapshotError> for ApiError {
324    fn from(e: tollgate_store::KeySnapshotError) -> Self {
325        use tollgate_store::KeySnapshotError;
326        let (status, code) = match &e {
327            // The answer revocation gives for a foreign or unknown key.
328            KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
329            // 409: well-formed, but the credential is terminally retired and is
330            // never granted positive authorization again (INVARIANTS.md GL-27).
331            KeySnapshotError::Retired { .. } => (StatusCode::CONFLICT, "credential-retired"),
332            KeySnapshotError::Publish(inner) => return inner.clone().into(),
333            KeySnapshotError::Storage(inner) => return inner.clone().into(),
334        };
335        ApiError {
336            status,
337            code,
338            title: e.to_string(),
339            generation: None,
340            balance_exhaustion: None,
341            balance_shortfall: None,
342        }
343    }
344}
345
346impl From<tollgate_store::BudgetError> for ApiError {
347    fn from(e: tollgate_store::BudgetError) -> Self {
348        match e {
349            tollgate_store::BudgetError::UnknownAccount => ApiError {
350                status: StatusCode::NOT_FOUND,
351                code: "unknown-account",
352                title: e.to_string(),
353                generation: None,
354                balance_exhaustion: None,
355                balance_shortfall: None,
356            },
357            tollgate_store::BudgetError::Storage(inner) => inner.into(),
358        }
359    }
360}
361
362impl From<SetStatusError> for ApiError {
363    fn from(e: SetStatusError) -> Self {
364        match e {
365            SetStatusError::UnknownAccount => ApiError {
366                status: StatusCode::NOT_FOUND,
367                code: "unknown-account",
368                title: e.to_string(),
369                generation: None,
370                balance_exhaustion: None,
371                balance_shortfall: None,
372            },
373            // 409, not 422: the request is well-formed and the operator is
374            // not at fault for asking. The account is simply in a state no
375            // transition leaves (INVARIANTS.md GL-22).
376            SetStatusError::AccountClosed => ApiError {
377                status: StatusCode::CONFLICT,
378                code: "account-closed",
379                title: e.to_string(),
380                generation: None,
381                balance_exhaustion: None,
382                balance_shortfall: None,
383            },
384            SetStatusError::Storage(inner) => ApiError::from(inner),
385        }
386    }
387}
388
389impl From<PublishSnapshotError> for ApiError {
390    fn from(e: PublishSnapshotError) -> Self {
391        match e {
392            PublishSnapshotError::CredentialMismatch { .. } => ApiError {
393                status: StatusCode::UNPROCESSABLE_ENTITY,
394                code: "invalid-credential-binding",
395                title: e.to_string(),
396                generation: None,
397                balance_exhaustion: None,
398                balance_shortfall: None,
399            },
400            PublishSnapshotError::StatusMismatch { .. } => ApiError {
401                status: StatusCode::CONFLICT,
402                code: "snapshot-status-mismatch",
403                title: e.to_string(),
404                generation: None,
405                balance_exhaustion: None,
406                balance_shortfall: None,
407            },
408            // 409 for the reason the status mismatch is: the request is
409            // well-formed and the operator is not at fault — the account
410            // simply owns this fact, and it is changed through its own
411            // endpoint (GL-99).
412            PublishSnapshotError::CapacityClassMismatch { .. } => ApiError {
413                status: StatusCode::CONFLICT,
414                code: "snapshot-capacity-class-mismatch",
415                title: e.to_string(),
416                generation: None,
417                balance_exhaustion: None,
418                balance_shortfall: None,
419            },
420            PublishSnapshotError::Storage(inner) => ApiError::from(inner),
421        }
422    }
423}
424
425impl From<StoreError> for ApiError {
426    fn from(_: StoreError) -> Self {
427        ApiError {
428            status: StatusCode::SERVICE_UNAVAILABLE,
429            code: "storage",
430            // A backend owns arbitrary text, which may include credentials or
431            // private row values. Never format it into a public diagnostic.
432            title: "backend unavailable".into(),
433            generation: None,
434            balance_exhaustion: None,
435            balance_shortfall: None,
436        }
437    }
438}
439
440impl From<IngestError> for ApiError {
441    fn from(error: IngestError) -> Self {
442        match error {
443            // The store could not answer. A client should retry, and 503 is
444            // the status that says so.
445            IngestError::Unavailable(e) => ApiError::from(e),
446            // The store examined this batch and refused it: an accounting
447            // total that cannot absorb these units will not absorb them on a
448            // replay either. 422 rather than 503, so a client can tell a
449            // refusal it must not repeat from an outage it should wait out —
450            // which is the distinction GL-61 is about, made at both ends of the
451            // wire rather than only at the transport.
452            IngestError::Refused(_) => ApiError {
453                status: StatusCode::UNPROCESSABLE_ENTITY,
454                code: "usage-refused",
455                title: "usage batch refused".into(),
456                generation: None,
457                balance_exhaustion: None,
458                balance_shortfall: None,
459            },
460        }
461    }
462}
463
464impl From<SnapshotValidationError> for ApiError {
465    fn from(error: SnapshotValidationError) -> Self {
466        ApiError {
467            status: StatusCode::UNPROCESSABLE_ENTITY,
468            code: "invalid-snapshot-limits",
469            title: error.to_string(),
470            generation: None,
471            balance_exhaustion: None,
472            balance_shortfall: None,
473        }
474    }
475}
476
477impl IntoResponse for ApiError {
478    fn into_response(self) -> Response {
479        self.render(|| diagnostic_id(getrandom::fill))
480    }
481}
482
483/// Only generated identifiers and static machine codes cross into diagnostics.
484/// The middleware receives this marker, never a backend error or response text.
485#[derive(Clone)]
486struct HttpFailure {
487    code: &'static str,
488    error_id: Option<String>,
489}
490
491fn diagnostic_id(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Option<String> {
492    let mut bytes = [0u8; 16];
493    fill(&mut bytes).ok()?;
494    Some(tollgate_core::RequestId(u128::from_be_bytes(bytes)).to_string())
495}
496
497impl ApiError {
498    fn render(self, new_id: impl FnOnce() -> Option<String>) -> Response {
499        let unauthorized = self.status == StatusCode::UNAUTHORIZED;
500        let failure =
501            (self.status.is_server_error() || self.code == "usage-refused").then(|| HttpFailure {
502                code: self.code,
503                error_id: new_id(),
504            });
505        let problem = Problem {
506            status: self.status.as_u16(),
507            code: self.code.to_string(),
508            title: self.title,
509            generation: self.generation,
510            balance_exhaustion: self.balance_exhaustion,
511            balance_shortfall: self.balance_shortfall,
512        };
513        // Keep the public Problem Rust shape intact. This optional JSON
514        // extension is ignored by existing clients and carries no authority.
515        #[derive(serde::Serialize)]
516        struct DiagnosticProblem {
517            #[serde(flatten)]
518            problem: Problem,
519            #[serde(skip_serializing_if = "Option::is_none")]
520            error_id: Option<String>,
521        }
522        let body = DiagnosticProblem {
523            problem,
524            error_id: failure
525                .as_ref()
526                .and_then(|failure| failure.error_id.clone()),
527        };
528        let mut response = (self.status, Json(body)).into_response();
529        if let Some(failure) = failure {
530            response.extensions_mut().insert(failure);
531        }
532        response.headers_mut().insert(
533            axum::http::header::CONTENT_TYPE,
534            axum::http::HeaderValue::from_static("application/problem+json"),
535        );
536        if unauthorized {
537            response.headers_mut().insert(
538                axum::http::header::WWW_AUTHENTICATE,
539                axum::http::HeaderValue::from_static("Bearer realm=\"tollgate-control\""),
540            );
541        }
542        response
543    }
544}
545
546/// The router owns failure reporting, including static route context. Raw
547/// paths, queries, headers, request bodies and backend text are never logged.
548pub(crate) async fn report_http_failure(request: Request, next: Next) -> Response {
549    let route = request.extensions().get::<MatchedPath>().cloned();
550    let response = next.run(request).await;
551    if let Some(failure) = response.extensions().get::<HttpFailure>() {
552        tracing::warn!(
553            target: "tollgate::diagnostics",
554            route = route.as_ref().map(MatchedPath::as_str).unwrap_or("unmatched"),
555            code = failure.code,
556            status = response.status().as_u16(),
557            error_id = failure.error_id.as_deref(),
558            error_id_unavailable = failure.error_id.is_none(),
559            "control-plane operation failed; consult backend health and retained operational records"
560        );
561    }
562    response
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn diagnostic_identifiers_use_all_entropy_and_surface_entropy_failure() {
571        let id = diagnostic_id(|bytes| {
572            bytes.copy_from_slice(&0x00112233445566778899aabbccddeeff_u128.to_be_bytes());
573            Ok(())
574        });
575        assert_eq!(id.as_deref(), Some("00112233445566778899aabbccddeeff"));
576        assert!(diagnostic_id(|_| Err(getrandom::Error::UNSUPPORTED)).is_none());
577    }
578
579    #[tokio::test]
580    async fn entropy_failure_preserves_the_error_without_inventing_an_identifier() {
581        use http_body_util::BodyExt;
582        let response = ApiError::from(StoreError("fixture-secret-70".into())).render(|| None);
583        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
584        let failure = response.extensions().get::<HttpFailure>().unwrap();
585        assert_eq!(failure.code, "storage");
586        assert!(failure.error_id.is_none());
587        let body = response.into_body().collect().await.unwrap().to_bytes();
588        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
589        assert_eq!(json["title"], "backend unavailable");
590        assert!(json.get("error_id").is_none());
591    }
592}