1use 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)]
30pub struct ApiError {
31 pub status: StatusCode,
33 pub code: &'static str,
36 pub title: String,
39 pub generation: Option<Generation>,
41 pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
43 pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
46}
47
48pub(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
70pub(crate) struct ApiPath<T>(pub T);
72
73pub(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 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 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 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 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 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 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 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 AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
255 (StatusCode::CONFLICT, "insufficient-balance")
256 }
257 AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
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 KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
303 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 KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
326 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 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 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 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 IngestError::Unavailable(e) => ApiError::from(e),
443 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#[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 #[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
543pub(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}