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)]
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
32pub(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
54pub(crate) struct ApiPath<T>(pub T);
56
57pub(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 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 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 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 KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
278 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 KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
301 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 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 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 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 IngestError::Unavailable(e) => ApiError::from(e),
418 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#[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 #[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
518pub(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}