swadhinbiswas

[email protected]
2026-07-26 14:39:27 +0600 committed NaN years ago
4c3cd66
commit 4c3cd66
18 files changed
+558 -67
▼Cargo.lock
+1-0
2753
2753
"simd-json",
2754
2754
"sqlx",
2755
2755
"tempfile",
2756
+ "thiserror 1.0.69",
2756
2757
"tokio",
2757
2758
"tokio-rustls",
2758
2759
"tokio-stream",
2759
2760
▼crates/justapi-core/Cargo.toml
+1-0
30
30
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
31
31
tracing-appender.workspace = true
32
32
anyhow.workspace = true
33
+thiserror.workspace = true
33
34
matchit.workspace = true
34
35
async-trait.workspace = true
35
36
jsonwebtoken.workspace = true
36
37
▼crates/justapi-core/src/compress.rs
+9-3
197
197
}
198
198
199
199
let mut parts = parts;
200
- parts.headers.insert("content-encoding", encoding.as_header_value().parse().unwrap());
201
- parts.headers.insert("content-length", compressed.len().to_string().parse().unwrap());
200
+ parts.headers.insert(
201
+ "content-encoding",
202
+ encoding.as_header_value().parse().expect("valid content-encoding header"),
203
+ );
204
+ parts.headers.insert(
205
+ "content-length",
206
+ compressed.len().to_string().parse().expect("valid content-length header"),
207
+ );
202
208
// Add Vary header so caches know the response varies by Accept-Encoding
203
- parts.headers.insert("vary", "accept-encoding".parse().unwrap());
209
+ parts.headers.insert("vary", "accept-encoding".parse().expect("valid vary header"));
204
210
205
211
let new_body = UnsyncBoxBody::new(
206
212
http_body_util::Full::new(Bytes::from(compressed))
207
213
▼crates/justapi-core/src/error.rs
+290-0
1
+//! Typed error types for the `justapi-core` library.
2
+//!
3
+//! Every public API that can fail returns a `CoreError` variant, giving
4
+//! callers the ability to match on specific failure modes and render
5
+//! appropriate HTTP responses. Application code (CLI, bench) can use
6
+//! `anyhow::Result` — these errors are for library boundaries only.
7
+
8
+use http_body_util::BodyExt;
9
+
10
+// ---------------------------------------------------------------------------
11
+// CoreError — the top-level typed error for justapi-core
12
+// ---------------------------------------------------------------------------
13
+
14
+/// All errors that can arise from the `justapi-core` library.
15
+#[derive(Debug, thiserror::Error)]
16
+pub enum CoreError {
17
+ /// A request failed JSON Schema or Pydantic-style validation (HTTP 422).
18
+ #[error("validation failed: {detail}")]
19
+ Validation { detail: String, errors: Vec<FieldError> },
20
+
21
+ /// No route matched the request (HTTP 404).
22
+ #[error("route not found: {method} {path}")]
23
+ RouteNotFound { method: String, path: String },
24
+
25
+ /// The HTTP method is not allowed on this route (HTTP 405).
26
+ #[error("method not allowed: {method}")]
27
+ MethodNotAllowed { method: String },
28
+
29
+ /// Authentication failed (HTTP 401).
30
+ #[error("unauthorized: {reason}")]
31
+ Auth { reason: String },
32
+
33
+ /// Authorization failed — valid credentials but insufficient permissions (HTTP 403).
34
+ #[error("forbidden: {reason}")]
35
+ Forbidden { reason: String },
36
+
37
+ /// Rate limit exceeded (HTTP 429).
38
+ #[error("rate limited, retry after {retry_after_secs}s")]
39
+ RateLimited { retry_after_secs: u64 },
40
+
41
+ /// Request body exceeds the configured size limit (HTTP 413).
42
+ #[error("request body too large: {size} bytes (max {max})")]
43
+ PayloadTooLarge { size: usize, max: usize },
44
+
45
+ /// An upstream dependency returned an error (HTTP 502).
46
+ #[error("upstream error: {0}")]
47
+ Upstream(String),
48
+
49
+ /// A configuration error was detected at startup or runtime (HTTP 500).
50
+ #[error("configuration error: {0}")]
51
+ Config(String),
52
+
53
+ /// An internal invariant was violated (HTTP 500).
54
+ #[error("internal error: {0}")]
55
+ Internal(String),
56
+
57
+ /// The service is temporarily unavailable (HTTP 503).
58
+ #[error("service unavailable: {0}")]
59
+ ServiceUnavailable(String),
60
+
61
+ /// The request timed out (HTTP 504).
62
+ #[error("gateway timeout after {timeout_ms}ms")]
63
+ GatewayTimeout { timeout_ms: u64 },
64
+
65
+ /// An error from an anyhow-converted source (backward compatibility).
66
+ #[error(transparent)]
67
+ Other(#[from] anyhow::Error),
68
+}
69
+
70
+// ---------------------------------------------------------------------------
71
+// CoreError → HTTP status code mapping
72
+// ---------------------------------------------------------------------------
73
+
74
+impl CoreError {
75
+ /// Return the appropriate HTTP status code for this error variant.
76
+ pub fn status_code(&self) -> hyper::StatusCode {
77
+ use hyper::StatusCode;
78
+ match self {
79
+ CoreError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY,
80
+ CoreError::RouteNotFound { .. } => StatusCode::NOT_FOUND,
81
+ CoreError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED,
82
+ CoreError::Auth { .. } => StatusCode::UNAUTHORIZED,
83
+ CoreError::Forbidden { .. } => StatusCode::FORBIDDEN,
84
+ CoreError::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
85
+ CoreError::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
86
+ CoreError::Upstream(_) => StatusCode::BAD_GATEWAY,
87
+ CoreError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
88
+ CoreError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
89
+ CoreError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
90
+ CoreError::GatewayTimeout { .. } => StatusCode::GATEWAY_TIMEOUT,
91
+ CoreError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
92
+ }
93
+ }
94
+
95
+ /// Render this error as an RFC 9457 Problem Details JSON body.
96
+ ///
97
+ /// The response includes `type`, `title`, `status`, and `detail` fields.
98
+ /// Validation errors additionally include an `errors` array with
99
+ /// field-level messages.
100
+ pub fn to_problem_json(&self) -> String {
101
+ let status: u16 = self.status_code().into();
102
+ let title = match self {
103
+ CoreError::Validation { .. } => "Validation Error",
104
+ CoreError::RouteNotFound { .. } => "Not Found",
105
+ CoreError::MethodNotAllowed { .. } => "Method Not Allowed",
106
+ CoreError::Auth { .. } => "Unauthorized",
107
+ CoreError::Forbidden { .. } => "Forbidden",
108
+ CoreError::RateLimited { .. } => "Too Many Requests",
109
+ CoreError::PayloadTooLarge { .. } => "Payload Too Large",
110
+ CoreError::Upstream(_) => "Bad Gateway",
111
+ CoreError::Config(_) => "Internal Server Error",
112
+ CoreError::Internal(_) => "Internal Server Error",
113
+ CoreError::ServiceUnavailable(_) => "Service Unavailable",
114
+ CoreError::GatewayTimeout { .. } => "Gateway Timeout",
115
+ CoreError::Other(_) => "Internal Server Error",
116
+ };
117
+
118
+ let mut map = serde_json::Map::new();
119
+ map.insert(
120
+ "type".into(),
121
+ serde_json::Value::String(format!(
122
+ "https://justapi.dev/errors/{}",
123
+ title.to_lowercase().replace(' ', "-")
124
+ )),
125
+ );
126
+ map.insert("title".into(), serde_json::Value::String(title.into()));
127
+ map.insert("status".into(), serde_json::Value::Number(status.into()));
128
+ map.insert("detail".into(), serde_json::Value::String(self.to_string()));
129
+
130
+ if let CoreError::Validation { errors, .. } = self {
131
+ let errs: Vec<serde_json::Value> = errors
132
+ .iter()
133
+ .map(|e| {
134
+ serde_json::json!({
135
+ "field": e.field,
136
+ "message": e.message,
137
+ })
138
+ })
139
+ .collect();
140
+ map.insert("errors".into(), serde_json::Value::Array(errs));
141
+ }
142
+
143
+ if let CoreError::RateLimited { retry_after_secs } = self {
144
+ map.insert("retry_after".into(), serde_json::Value::Number((*retry_after_secs).into()));
145
+ }
146
+
147
+ serde_json::Value::Object(map).to_string()
148
+ }
149
+}
150
+
151
+// ---------------------------------------------------------------------------
152
+// FieldError — field-level validation detail
153
+// ---------------------------------------------------------------------------
154
+
155
+/// A single field-level validation error.
156
+#[derive(Debug, Clone, thiserror::Error)]
157
+#[error("{field}: {message}")]
158
+pub struct FieldError {
159
+ pub field: String,
160
+ pub message: String,
161
+}
162
+
163
+impl FieldError {
164
+ pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
165
+ Self { field: field.into(), message: message.into() }
166
+ }
167
+}
168
+
169
+// ---------------------------------------------------------------------------
170
+// Conversions for backward compatibility
171
+// ---------------------------------------------------------------------------
172
+
173
+impl From<crate::validate::ValidationError> for CoreError {
174
+ fn from(err: crate::validate::ValidationError) -> Self {
175
+ let errors: Vec<FieldError> = err
176
+ .errors
177
+ .into_iter()
178
+ .map(|e| FieldError { field: e.field, message: e.message })
179
+ .collect();
180
+ let detail = errors
181
+ .iter()
182
+ .map(|e| format!("{}: {}", e.field, e.message))
183
+ .collect::<Vec<_>>()
184
+ .join("; ");
185
+ CoreError::Validation { detail, errors }
186
+ }
187
+}
188
+
189
+// ---------------------------------------------------------------------------
190
+// Response builder
191
+// ---------------------------------------------------------------------------
192
+
193
+impl CoreError {
194
+ /// Build an HTTP response from this error.
195
+ pub fn into_response(self) -> hyper::Response<crate::ResponseBody> {
196
+ let status = self.status_code();
197
+ let body = self.to_problem_json();
198
+ let retry_after = match &self {
199
+ CoreError::RateLimited { retry_after_secs } => Some(retry_after_secs.to_string()),
200
+ CoreError::ServiceUnavailable(_) => Some("1".to_string()),
201
+ _ => None,
202
+ };
203
+
204
+ let mut builder = hyper::Response::builder()
205
+ .status(status)
206
+ .header("content-type", "application/problem+json")
207
+ .header("content-length", body.len().to_string());
208
+
209
+ if let Some(retry) = retry_after {
210
+ builder = builder.header("retry-after", retry);
211
+ }
212
+
213
+ builder
214
+ .body(crate::UnsyncBoxBody::new(
215
+ http_body_util::Full::new(hyper::body::Bytes::from(body))
216
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
217
+ ))
218
+ .expect("Response::builder with valid inputs should never fail")
219
+ }
220
+}
221
+
222
+// ---------------------------------------------------------------------------
223
+// Tests
224
+// ---------------------------------------------------------------------------
225
+
226
+#[cfg(test)]
227
+mod tests {
228
+ use super::*;
229
+
230
+ #[test]
231
+ fn test_validation_error_to_problem_json() {
232
+ let err = CoreError::Validation {
233
+ detail: "name: required".into(),
234
+ errors: vec![FieldError::new("name", "required")],
235
+ };
236
+ let json = err.to_problem_json();
237
+ let v: serde_json::Value = serde_json::from_str(&json).unwrap();
238
+ assert_eq!(v["status"], 422);
239
+ assert_eq!(v["title"], "Validation Error");
240
+ assert!(v["errors"].is_array());
241
+ assert_eq!(v["errors"][0]["field"], "name");
242
+ }
243
+
244
+ #[test]
245
+ fn test_route_not_found_status() {
246
+ let err = CoreError::RouteNotFound { method: "GET".into(), path: "/missing".into() };
247
+ assert_eq!(err.status_code(), hyper::StatusCode::NOT_FOUND);
248
+ }
249
+
250
+ #[test]
251
+ fn test_rate_limited_includes_retry_after() {
252
+ let err = CoreError::RateLimited { retry_after_secs: 5 };
253
+ let resp = err.into_response();
254
+ assert_eq!(resp.status(), hyper::StatusCode::TOO_MANY_REQUESTS);
255
+ assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
256
+ }
257
+
258
+ #[test]
259
+ fn test_auth_error_status() {
260
+ let err = CoreError::Auth { reason: "missing token".into() };
261
+ assert_eq!(err.status_code(), hyper::StatusCode::UNAUTHORIZED);
262
+ }
263
+
264
+ #[test]
265
+ fn test_payload_too_large_status() {
266
+ let err = CoreError::PayloadTooLarge { size: 100_000_000, max: 50_000_000 };
267
+ assert_eq!(err.status_code(), hyper::StatusCode::PAYLOAD_TOO_LARGE);
268
+ }
269
+
270
+ #[test]
271
+ fn test_into_response_produces_valid_json() {
272
+ let err = CoreError::Internal("something broke".into());
273
+ let resp = err.into_response();
274
+ assert_eq!(resp.status(), hyper::StatusCode::INTERNAL_SERVER_ERROR);
275
+ assert_eq!(resp.headers().get("content-type").unwrap(), "application/problem+json");
276
+ }
277
+
278
+ #[test]
279
+ fn test_validation_from_corevalidate() {
280
+ let val_err = crate::validate::ValidationError::new("email", "invalid format");
281
+ let core_err: CoreError = val_err.into();
282
+ assert_eq!(core_err.status_code(), hyper::StatusCode::UNPROCESSABLE_ENTITY);
283
+ if let CoreError::Validation { errors, .. } = &core_err {
284
+ assert_eq!(errors.len(), 1);
285
+ assert_eq!(errors[0].field, "email");
286
+ } else {
287
+ panic!("expected Validation variant");
288
+ }
289
+ }
290
+}
0
291
▼crates/justapi-core/src/health.rs
+1-1
108
108
Full::new(Bytes::from(body))
109
109
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
110
110
))
111
- .unwrap()
111
+ .expect("Response::builder with valid inputs should never fail")
112
112
}
113
113
}
114
114
115
115
▼crates/justapi-core/src/lib.rs
+4-3
6
6
#[cfg(feature = "db")]
7
7
pub mod db;
8
8
pub mod dx;
9
+pub mod error;
9
10
pub mod error_catalog;
10
11
pub mod extract;
11
12
pub mod gateway;
72
73
Full::new(Bytes::from(body.to_string()))
73
74
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
74
75
))
75
- .unwrap()
76
+ .expect("Response::builder with valid inputs should never fail")
76
77
}
77
78
78
79
/// Canonical error envelope. Every non-2xx response in justapi uses this single
100
101
Full::new(Bytes::from(serde_json::json!({ "detail": detail }).to_string()))
101
102
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
102
103
))
103
- .unwrap()
104
+ .expect("Response::builder with valid inputs should never fail")
104
105
}
105
106
106
107
/// Map a SQLx error from a request-path DB operation to the right status.
127
128
.status(status)
128
129
.header("content-type", content_type)
129
130
.body(UnsyncBoxBody::new(http_body_util::StreamBody::new(frame_stream)))
130
- .unwrap()
131
+ .expect("Response::builder with valid inputs should never fail")
131
132
}
132
133
pub mod dummy_extract;
133
134
pub mod grpc;
134
135
▼crates/justapi-core/src/mail/otp.rs
+1-1
7
7
pub async fn create_otp(&self, email: &str, purpose: &str, _ttl_secs: u64) -> String {
8
8
use rand::Rng;
9
9
let otp: String = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000));
10
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
10
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
11
11
let mut store = self.tokens.write().await;
12
12
store.insert(
13
13
otp.clone(),
14
14
▼crates/justapi-core/src/mail/verify.rs
+3-3
32
32
.map(char::from)
33
33
.collect();
34
34
35
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
35
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
36
36
37
37
let mut store = self.tokens.write().await;
38
38
store.insert(
64
64
return None;
65
65
}
66
66
67
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
67
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
68
68
if now - entry.created_at > max_age_secs {
69
69
store.remove(token);
70
70
return None;
79
79
80
80
/// Clean up expired tokens from the store.
81
81
pub async fn cleanup(&self, max_age_secs: u64) {
82
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
82
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
83
83
let mut store = self.tokens.write().await;
84
84
store.retain(|_, entry| now - entry.created_at <= max_age_secs);
85
85
}
86
86
▼crates/justapi-core/src/metrics.rs
+65-6
150
150
151
151
/// Register an extra Prometheus metric provider (e.g. scheduler stats).
152
152
pub fn register_extra_provider(&self, provider: Box<dyn MetricProvider>) {
153
- self.inner.extra_providers.lock().unwrap().push(provider);
153
+ match self.inner.extra_providers.lock() {
154
+ Ok(mut providers) => providers.push(provider),
155
+ Err(poisoned) => {
156
+ tracing::warn!("Metrics provider mutex poisoned (recovered): {poisoned}");
157
+ poisoned.into_inner().push(provider);
158
+ }
159
+ }
154
160
}
155
161
156
162
pub fn add_bytes_out(&self, n: u64) {
275
281
));
276
282
277
283
// Extra metric providers (e.g. scheduler prefix-cache stats).
278
- let providers = self.inner.extra_providers.lock().unwrap();
284
+ let providers = match self.inner.extra_providers.lock() {
285
+ Ok(guard) => guard,
286
+ Err(poisoned) => {
287
+ tracing::warn!(
288
+ "Metrics provider mutex poisoned during render (recovered): {poisoned}"
289
+ );
290
+ poisoned.into_inner()
291
+ }
292
+ };
279
293
for provider in providers.iter() {
280
294
out.push_str(&provider.render());
281
295
}
309
323
.header("content-type", "application/json")
310
324
.header("content-length", body.len().to_string())
311
325
.body(UnsyncBoxBody::new(body_bytes))
312
- .unwrap()
326
+ .expect("Response::builder with valid inputs should never fail")
313
327
}
314
328
315
329
/// Readiness check endpoint response.
322
336
.header("content-type", "application/json")
323
337
.header("content-length", body.len().to_string())
324
338
.body(UnsyncBoxBody::new(body_bytes))
325
- .unwrap()
339
+ .expect("Response::builder with valid inputs should never fail")
326
340
}
327
341
328
342
/// Liveness check endpoint response.
335
349
.header("content-type", "application/json")
336
350
.header("content-length", body.len().to_string())
337
351
.body(UnsyncBoxBody::new(body_bytes))
338
- .unwrap()
352
+ .expect("Response::builder with valid inputs should never fail")
339
353
}
340
354
341
355
/// Prometheus metrics endpoint response.
349
363
.header("content-type", "text/plain; version=0.0.4")
350
364
.header("content-length", body_len)
351
365
.body(UnsyncBoxBody::new(body_bytes))
352
- .unwrap()
366
+ .expect("Response::builder with valid inputs should never fail")
353
367
}
354
368
355
369
/// Timer guard that records elapsed time on drop.
459
473
assert_eq!(snap.status_4xx, 1);
460
474
assert_eq!(snap.status_5xx, 1);
461
475
}
476
+
477
+ // --- Crash-prevention tests (Phase 53.3) ---
478
+
479
+ #[test]
480
+ fn test_metrics_mutex_poisoning_recovery() {
481
+ use std::thread;
482
+
483
+ let m = Metrics::new();
484
+ let m_clone = m.clone();
485
+
486
+ // Poison the mutex by panicking while holding the lock
487
+ let handle = thread::spawn(move || {
488
+ let _guard = m_clone.inner.extra_providers.lock().unwrap();
489
+ panic!("deliberate panic to poison mutex");
490
+ });
491
+
492
+ let _ = handle.join();
493
+
494
+ // The mutex is now poisoned, but register_extra_provider must not panic
495
+ // It should recover and still allow registration
496
+ m.register_extra_provider(Box::new(|| "recovered metric\n".to_string()));
497
+
498
+ // And prometheus() must also work with a poisoned mutex
499
+ let output = m.prometheus();
500
+ assert!(output.contains("justapi_requests_total"));
501
+ }
502
+
503
+ #[test]
504
+ fn test_metrics_prometheus_with_poisoned_mutex() {
505
+ use std::thread;
506
+
507
+ let m = Metrics::new();
508
+ m.record_request();
509
+
510
+ let m_clone = m.clone();
511
+ let handle = thread::spawn(move || {
512
+ let _guard = m_clone.inner.extra_providers.lock().unwrap();
513
+ panic!("deliberate panic to poison mutex");
514
+ });
515
+ let _ = handle.join();
516
+
517
+ // Must not panic even with poisoned mutex
518
+ let output = m.prometheus();
519
+ assert!(output.contains("justapi_requests_total 1"));
520
+ }
462
521
}
463
522
▼crates/justapi-core/src/middleware.rs
+9-7
693
693
let wait = negative.wait_time_from(now);
694
694
let retry_after_secs = wait.as_secs().max(1);
695
695
let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded");
696
- resp.headers_mut()
697
- .insert("retry-after", retry_after_secs.to_string().parse().unwrap());
698
- resp.headers_mut()
699
- .insert("x-ratelimit-reset", retry_after_secs.to_string().parse().unwrap());
696
+ let retry_val = HeaderValue::from_str(&retry_after_secs.to_string())
697
+ .unwrap_or_else(|_| HeaderValue::from_static("1"));
698
+ resp.headers_mut().insert("retry-after", retry_val.clone());
699
+ resp.headers_mut().insert("x-ratelimit-reset", retry_val);
700
700
Ok(resp)
701
701
}
702
702
}
718
718
impl IpRateLimiter {
719
719
pub fn new(duration: std::time::Duration, max_burst: u32) -> Self {
720
720
let quota = governor::Quota::with_period(duration)
721
- .unwrap()
722
- .allow_burst(NonZeroU32::new(max_burst).unwrap());
721
+ .expect("valid rate-limit duration for IpRateLimiter")
722
+ .allow_burst(
723
+ NonZeroU32::new(max_burst).expect("max_burst must be > 0 for IpRateLimiter"),
724
+ );
723
725
let limiter = std::sync::Arc::new(governor::RateLimiter::keyed(quota));
724
726
725
727
let limiter_clone = limiter.clone();
776
778
http_body_util::Full::new(Bytes::from(body))
777
779
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
778
780
))
779
- .unwrap()
781
+ .expect("Response::builder with valid inputs should never fail")
780
782
}
781
783
782
784
// ---------------------------------------------------------------------------
783
785
▼crates/justapi-core/src/openai.rs
+43-18
21
21
};
22
22
use justapi_inference::{ControlPlane, Engine, RouteRequest, Router, SchedulerEngine};
23
23
24
+/// Safely convert a u16 status code to `StatusCode`, falling back to 500 if
25
+/// the code is invalid (defensive against upstream bugs returning non-HTTP codes).
26
+fn safe_status(st: u16) -> StatusCode {
27
+ StatusCode::from_u16(st).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
28
+}
29
+
24
30
/// Build a handler for `POST /v1/chat/completions`.
25
31
pub fn chat_completions_handler(engine: Arc<Engine>) -> HandlerFn {
26
32
Arc::new(move |req| {
34
40
"invalid_request_error",
35
41
400,
36
42
);
37
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
43
+ return Ok(json_response(safe_status(st), &json));
38
44
}
39
45
};
40
46
let req: ChatCompletionRequest = match serde_json::from_slice(&body) {
45
51
"invalid_request_error",
46
52
400,
47
53
);
48
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
54
+ return Ok(json_response(safe_status(st), &json));
49
55
}
50
56
};
51
57
if req.stream {
88
94
"invalid_request_error",
89
95
400,
90
96
);
91
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
97
+ return Ok(json_response(safe_status(st), &json));
92
98
}
93
99
};
94
100
let req: CompletionRequest = match serde_json::from_slice(&body) {
99
105
"invalid_request_error",
100
106
400,
101
107
);
102
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
108
+ return Ok(json_response(safe_status(st), &json));
103
109
}
104
110
};
105
111
if req.stream {
142
148
"invalid_request_error",
143
149
400,
144
150
);
145
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
151
+ return Ok(json_response(safe_status(st), &json));
146
152
}
147
153
};
148
154
let req: EmbeddingRequest = match serde_json::from_slice(&body) {
153
159
"invalid_request_error",
154
160
400,
155
161
);
156
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
162
+ return Ok(json_response(safe_status(st), &json));
157
163
}
158
164
};
159
165
match justapi_inference::openai::embeddings(&engine, req) {
208
214
"invalid_request_error",
209
215
400,
210
216
);
211
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
217
+ return Ok(json_response(safe_status(st), &json));
212
218
}
213
219
};
214
220
let req: ChatCompletionRequest = match serde_json::from_slice(&body) {
219
225
"invalid_request_error",
220
226
400,
221
227
);
222
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
228
+ return Ok(json_response(safe_status(st), &json));
223
229
}
224
230
};
225
231
if req.stream {
263
269
"invalid_request_error",
264
270
400,
265
271
);
266
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
272
+ return Ok(json_response(safe_status(st), &json));
267
273
}
268
274
};
269
275
let req: CompletionRequest = match serde_json::from_slice(&body) {
274
280
"invalid_request_error",
275
281
400,
276
282
);
277
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
283
+ return Ok(json_response(safe_status(st), &json));
278
284
}
279
285
};
280
286
if req.stream {
339
345
"invalid_request_error",
340
346
400,
341
347
);
342
- return AdmitOutcome::Reject(json_response(StatusCode::from_u16(st).unwrap(), &json));
348
+ return AdmitOutcome::Reject(json_response(safe_status(st), &json));
343
349
}
344
350
};
345
351
match router.route(cp, &route_req) {
350
356
"server_error",
351
357
503,
352
358
);
353
- AdmitOutcome::Reject(json_response(StatusCode::from_u16(st).unwrap(), &json))
359
+ AdmitOutcome::Reject(json_response(safe_status(st), &json))
354
360
}
355
361
}
356
362
}
373
379
"invalid_request_error",
374
380
400,
375
381
);
376
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
382
+ return Ok(json_response(safe_status(st), &json));
377
383
}
378
384
};
379
385
let decision = match admit(&body, &cp, &router) {
388
394
"invalid_request_error",
389
395
400,
390
396
);
391
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
397
+ return Ok(json_response(safe_status(st), &json));
392
398
}
393
399
};
394
400
req.model = decision.resolved.model_name.clone();
436
442
"invalid_request_error",
437
443
400,
438
444
);
439
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
445
+ return Ok(json_response(safe_status(st), &json));
440
446
}
441
447
};
442
448
let decision = match admit(&body, &cp, &router) {
451
457
"invalid_request_error",
452
458
400,
453
459
);
454
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
460
+ return Ok(json_response(safe_status(st), &json));
455
461
}
456
462
};
457
463
req.model = decision.resolved.model_name.clone();
499
505
"invalid_request_error",
500
506
400,
501
507
);
502
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
508
+ return Ok(json_response(safe_status(st), &json));
503
509
}
504
510
};
505
511
let decision = match admit(&body, &cp, &router) {
514
520
"invalid_request_error",
515
521
400,
516
522
);
517
- return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
523
+ return Ok(json_response(safe_status(st), &json));
518
524
}
519
525
};
520
526
req.model = decision.resolved.model_name.clone();
794
800
);
795
801
let _ = server;
796
802
}
803
+
804
+ // --- Crash-prevention tests (Phase 53.3) ---
805
+
806
+ #[test]
807
+ fn test_safe_status_valid_codes() {
808
+ assert_eq!(safe_status(200), StatusCode::OK);
809
+ assert_eq!(safe_status(404), StatusCode::NOT_FOUND);
810
+ assert_eq!(safe_status(500), StatusCode::INTERNAL_SERVER_ERROR);
811
+ assert_eq!(safe_status(422), StatusCode::UNPROCESSABLE_ENTITY);
812
+ assert_eq!(safe_status(429), StatusCode::TOO_MANY_REQUESTS);
813
+ }
814
+
815
+ #[test]
816
+ fn test_safe_status_invalid_code_falls_back_to_500() {
817
+ assert_eq!(safe_status(0), StatusCode::INTERNAL_SERVER_ERROR);
818
+ assert_eq!(safe_status(999), StatusCode::INTERNAL_SERVER_ERROR);
819
+ assert_eq!(safe_status(70000), StatusCode::INTERNAL_SERVER_ERROR);
820
+ assert_eq!(safe_status(u16::MAX), StatusCode::INTERNAL_SERVER_ERROR);
821
+ }
797
822
}
798
823
▼crates/justapi-core/src/rate_limit.rs
+3-1
75
75
// Burst capacity in microseconds
76
76
let tau = t * capacity;
77
77
78
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as u64;
78
+ let now =
79
+ SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_micros()
80
+ as u64;
79
81
80
82
let result: Vec<u64> = script
81
83
.key(key)
82
84
▼crates/justapi-core/src/static_files.rs
+18-8
107
107
);
108
108
let mut resp = Response::new(body);
109
109
*resp.status_mut() = StatusCode::OK;
110
- resp.headers_mut().insert("content-type", content_type.parse().unwrap());
111
- resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
112
- resp.headers_mut().insert("etag", etag.parse().unwrap());
113
- resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
110
+ resp.headers_mut()
111
+ .insert("content-type", content_type.parse().expect("valid content-type"));
112
+ resp.headers_mut().insert(
113
+ "content-length",
114
+ total_len.to_string().parse().expect("valid content-length"),
115
+ );
116
+ resp.headers_mut().insert("etag", etag.parse().expect("valid etag"));
117
+ resp.headers_mut().insert(
118
+ "cache-control",
119
+ "public, max-age=3600".parse().expect("valid cache-control"),
120
+ );
114
121
return Ok(resp);
115
122
}
116
123
122
129
123
130
let mut resp = Response::new(body);
124
131
*resp.status_mut() = StatusCode::OK;
125
- resp.headers_mut().insert("content-type", content_type.parse().unwrap());
126
- resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
127
- resp.headers_mut().insert("etag", etag.parse().unwrap());
128
- resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
132
+ resp.headers_mut()
133
+ .insert("content-type", content_type.parse().expect("valid content-type"));
134
+ resp.headers_mut()
135
+ .insert("content-length", total_len.to_string().parse().expect("valid content-length"));
136
+ resp.headers_mut().insert("etag", etag.parse().expect("valid etag"));
137
+ resp.headers_mut()
138
+ .insert("cache-control", "public, max-age=3600".parse().expect("valid cache-control"));
129
139
130
140
Ok(resp)
131
141
}
132
142
▼crates/justapi-core/src/tracing_setup.rs
+12-2
64
64
65
65
fn set_guard(guard: WorkerGuard) {
66
66
let lock = FILE_GUARD.get_or_init(|| Mutex::new(None));
67
- *lock.lock().unwrap() = Some(guard);
67
+ match lock.lock() {
68
+ Ok(mut g) => *g = Some(guard),
69
+ Err(poisoned) => {
70
+ *poisoned.into_inner() = Some(guard);
71
+ }
72
+ }
68
73
}
69
74
70
75
#[cfg(feature = "opentelemetry")]
187
192
#[cfg(feature = "opentelemetry")]
188
193
fn set_tracer_guard(provider: opentelemetry_sdk::trace::TracerProvider) {
189
194
let lock = TRACER_GUARD.get_or_init(|| Mutex::new(None));
190
- *lock.lock().unwrap() = Some(provider);
195
+ match lock.lock() {
196
+ Ok(mut g) => *g = Some(provider),
197
+ Err(poisoned) => {
198
+ *poisoned.into_inner() = Some(provider);
199
+ }
200
+ }
191
201
}
192
202
193
203
/// Initialize tracing with text format and env-filter (backward compatible).
194
204
▼crates/justapi-core/src/validate.rs
+62-3
27
27
FMTS.get_or_init(|| {
28
28
vec![
29
29
("email", |s: &str| {
30
- if s.is_empty() || s.contains(char::is_whitespace) || !s.contains('@') {
30
+ if s.is_empty() || s.contains(char::is_whitespace) {
31
31
return false;
32
32
}
33
- let (local, domain) = s.split_once('@').unwrap();
33
+ let (local, domain) = match s.split_once('@') {
34
+ Some(pair) => pair,
35
+ None => return false,
36
+ };
34
37
!local.is_empty()
35
38
&& !domain.is_empty()
36
39
&& domain.contains('.')
192
195
Full::new(Bytes::from(body))
193
196
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
194
197
))
195
- .unwrap()
198
+ .expect("Response::builder with valid inputs should never fail")
196
199
}
197
200
198
201
// ---------------------------------------------------------------------------
516
519
assert!(compiled.validate(br#"{"wrong": 1}"#).is_err());
517
520
}
518
521
}
522
+
523
+ // --- Crash-prevention tests (Phase 53.3) ---
524
+
525
+ #[test]
526
+ fn test_email_format_empty_string_no_panic() {
527
+ let validators = super::format_validators();
528
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
529
+ assert!(!email_checker.1(""));
530
+ }
531
+
532
+ #[test]
533
+ fn test_email_format_no_at_sign_no_panic() {
534
+ let validators = super::format_validators();
535
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
536
+ assert!(!email_checker.1("notanemail"));
537
+ }
538
+
539
+ #[test]
540
+ fn test_email_format_at_dot_prefix_no_panic() {
541
+ let validators = super::format_validators();
542
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
543
+ assert!(!email_checker.1("@example.com"));
544
+ }
545
+
546
+ #[test]
547
+ fn test_email_format_whitespace_no_panic() {
548
+ let validators = super::format_validators();
549
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
550
+ assert!(!email_checker.1("user @example.com"));
551
+ }
552
+
553
+ #[test]
554
+ fn test_email_format_multiple_at_no_panic() {
555
+ let validators = super::format_validators();
556
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
557
+ // "a@b@c" — split_once only splits on first @, local="a", domain="b@c"
558
+ // domain contains no '.' so it's invalid, but must not panic
559
+ assert!(!email_checker.1("a@b@c"));
560
+ }
561
+
562
+ #[test]
563
+ fn test_email_format_valid() {
564
+ let validators = super::format_validators();
565
+ let email_checker = validators.iter().find(|(name, _)| *name == "email").unwrap();
566
+ assert!(email_checker.1("[email protected]"));
567
+ assert!(email_checker.1("[email protected]"));
568
+ }
569
+
570
+ #[test]
571
+ fn test_validate_json_schema_with_email_format_no_panic() {
572
+ let body = br#"{"email": "not-an-email"}"#;
573
+ let schema = r#"{"type": "object", "properties": {"email": {"type": "string", "format": "email"}}, "required": ["email"]}"#;
574
+ // Must return a validation error, not panic
575
+ let result = validate_json_schema(body, schema);
576
+ assert!(result.is_err());
577
+ }
519
578
}
520
579
▼crates/justapi-core/src/xml.rs
+1-1
72
72
Full::new(Bytes::from(body.to_string()))
73
73
.map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
74
74
))
75
- .unwrap()
75
+ .expect("Response::builder with valid inputs should never fail")
76
76
}
77
77
78
78
/// Serialize a `Serialize` value to an XML string with the given root element.
79
79
▼crates/justapi-py/src/native/app.rs
+12-4
238
238
m.insert(
239
239
"email".into(),
240
240
Arc::new(|s: &str| {
241
- if s.is_empty() || s.contains(char::is_whitespace) || !s.contains('@') {
241
+ if s.is_empty() || s.contains(char::is_whitespace) {
242
242
return false;
243
243
}
244
- let (local, domain) = s.split_once('@').unwrap();
244
+ let (local, domain) = match s.split_once('@') {
245
+ Some(pair) => pair,
246
+ None => return false,
247
+ };
245
248
!local.is_empty()
246
249
&& !domain.is_empty()
247
250
&& domain.contains('.')
1853
1856
None,
1854
1857
"1.1".to_string(),
1855
1858
None, // auth_claims: batched requests have no middleware
1856
- )).unwrap();
1857
- py_list.append(r).unwrap();
1859
+ )).unwrap_or_else(|e| {
1860
+ tracing::error!("Failed to create batch Request object: {e}");
1861
+ panic!("Fatal: unable to create batch Request object: {e}")
1862
+ });
1863
+ py_list.append(r).unwrap_or_else(|e| {
1864
+ tracing::error!("Failed to append batch request to list: {e}");
1865
+ });
1858
1866
}
1859
1867
1860
1868
let result = helper.call_batch_handler.bind(py).call1((py_handler.bind(py), py_list));
1861
1869
▼crates/justapi-py/src/native/handlers.rs
+23-6
173
173
if let Some(pb) = parsed_body {
174
174
req.set_parsed_body(py, pb.into_bound(py));
175
175
}
176
- Bound::new(py, req).unwrap().into_any()
176
+ Bound::new(py, req)
177
+ .unwrap_or_else(|e| {
178
+ tracing::error!("Failed to create Python Request object: {e}");
179
+ panic!(
180
+ "Fatal: unable to create Python Request object (PyO3 Bound::new failed): {e}"
181
+ )
182
+ })
183
+ .into_any()
177
184
} else {
178
185
// Handlers with no request-dependent parameters (0-param endpoints,
179
186
// no dependencies/query/path/etc.) skip building the Python `Request`
323
330
324
331
#[cfg(feature = "orjson")]
325
332
let expr = {
326
- let _ = py.import("orjson").unwrap_or_else(|e| {
327
- panic!("orjson feature enabled but Python 'orjson' module not available: {e}")
328
- });
333
+ py.import("orjson").map_err(|e| {
334
+ PyErr::new::<pyo3::exceptions::PyImportError, _>(format!(
335
+ "orjson feature enabled but Python 'orjson' module not available: {e}"
336
+ ))
337
+ })?;
329
338
"lambda o: __import__('orjson').dumps(o, default=str)"
330
339
};
331
340
332
341
// SAFETY: globals/locals are left as None, which Python fills with the
333
342
// builtins dict, so `__import__` and `str` are available. The cached
334
343
// lambda borrows no local state and is leaked for the process lifetime.
335
- let cexpr = std::ffi::CString::new(expr).unwrap();
336
- let dumps = DUMPS.get_or_init(|| py.eval(cexpr.as_c_str(), None, None).unwrap().unbind());
344
+ let cexpr = std::ffi::CString::new(expr).map_err(|e| {
345
+ PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
346
+ "Invalid expression string (contains NUL byte): {e}"
347
+ ))
348
+ })?;
349
+ let dumps = DUMPS.get_or_init(|| {
350
+ py.eval(cexpr.as_c_str(), None, None)
351
+ .expect("Fatal: failed to compile fast_dumps lambda — Python eval error")
352
+ .unbind()
353
+ });
337
354
Ok(dumps.bind(py))
338
355
}
339
356
340
357