a1a2d89
commit a1a2d89
18 files changed
+558 -67
Showing 18 changed files+558-67
Cargo.lockcrates/justapi-core/Cargo.tomlcrates/justapi-core/src/compress.rscrates/justapi-core/src/error.rscrates/justapi-core/src/health.rscrates/justapi-core/src/lib.rscrates/justapi-core/src/mail/otp.rscrates/justapi-core/src/mail/verify.rscrates/justapi-core/src/metrics.rscrates/justapi-core/src/middleware.rscrates/justapi-core/src/openai.rscrates/justapi-core/src/rate_limit.rscrates/justapi-core/src/static_files.rscrates/justapi-core/src/tracing_setup.rscrates/justapi-core/src/validate.rscrates/justapi-core/src/xml.rscrates/justapi-py/src/native/app.rscrates/justapi-py/src/native/handlers.rs
▼Cargo.lock
+1-0
| @@ -2753,6 +2753,7 @@ dependencies = [ | ||
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,6 +30,7 @@ tracing.workspace = true | ||
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,10 +197,16 @@ impl<B: Send + 'static> crate::middleware::Middleware<B> for CompressionMiddlewa | ||
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
| @@ -0,0 +1,290 @@ | ||
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,7 +108,7 @@ impl HealthRegistry { | ||
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 +6,7 @@ pub mod compress; | ||
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,7 +73,7 @@ pub fn json_response(status: hyper::StatusCode, body: &str) -> Response<Response | ||
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,7 +101,7 @@ pub fn service_unavailable_response(detail: &str) -> Response<ResponseBody> { | ||
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,7 +128,7 @@ pub fn streaming_response( | ||
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 +7,7 @@ impl VerificationStore { | ||
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,7 +32,7 @@ impl VerificationStore { | ||
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,7 +64,7 @@ impl VerificationStore { | ||
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,7 +79,7 @@ impl VerificationStore { | ||
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,7 +150,13 @@ impl Metrics { | ||
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,7 +281,15 @@ impl Metrics { | ||
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,7 +323,7 @@ pub fn health_response() -> Response<ResponseBody> { | ||
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,7 +336,7 @@ pub fn ready_response() -> Response<ResponseBody> { | ||
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,7 +349,7 @@ pub fn live_response() -> Response<ResponseBody> { | ||
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,7 +363,7 @@ pub fn metrics_response(metrics: &Metrics) -> Response<ResponseBody> { | ||
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,4 +473,49 @@ mod tests { | ||
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,10 +693,10 @@ impl<B: Send + 'static> Middleware<B> for RateLimiter { | ||
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,8 +718,10 @@ pub struct IpRateLimiter { | ||
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,7 +778,7 @@ fn json_error(status: StatusCode, msg: &str) -> Response<ResponseBody> { | ||
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,6 +21,12 @@ use justapi_inference::openai::{ | ||
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,7 +40,7 @@ pub fn chat_completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +51,7 @@ pub fn chat_completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +94,7 @@ pub fn completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +105,7 @@ pub fn completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +148,7 @@ pub fn embeddings_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +159,7 @@ pub fn embeddings_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,7 +214,7 @@ pub fn scheduled_chat_completions_handler(engine: Arc<SchedulerEngine>) -> Handl | ||
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,7 +225,7 @@ pub fn scheduled_chat_completions_handler(engine: Arc<SchedulerEngine>) -> Handl | ||
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,7 +269,7 @@ pub fn scheduled_completions_handler(engine: Arc<SchedulerEngine>) -> HandlerFn | ||
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,7 +280,7 @@ pub fn scheduled_completions_handler(engine: Arc<SchedulerEngine>) -> HandlerFn | ||
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,7 +345,7 @@ fn admit(body: &[u8], cp: &ControlPlane, router: &Router) -> AdmitOutcome { | ||
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,7 +356,7 @@ fn admit(body: &[u8], cp: &ControlPlane, router: &Router) -> AdmitOutcome { | ||
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,7 +379,7 @@ pub fn routed_chat_completions_handler( | ||
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,7 +394,7 @@ pub fn routed_chat_completions_handler( | ||
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,7 +442,7 @@ pub fn routed_completions_handler( | ||
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,7 +457,7 @@ pub fn routed_completions_handler( | ||
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,7 +505,7 @@ pub fn routed_embeddings_handler( | ||
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,7 +520,7 @@ pub fn routed_embeddings_handler( | ||
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,4 +800,23 @@ mod tests { | ||
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,7 +75,9 @@ impl RateLimiter { | ||
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,10 +107,17 @@ impl StaticDir { | ||
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,10 +129,13 @@ impl StaticDir { | ||
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,7 +64,12 @@ static FILE_GUARD: OnceLock<Mutex<Option<WorkerGuard>>> = OnceLock::new(); | ||
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,7 +192,12 @@ use opentelemetry_otlp::WithExportConfig; | ||
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,10 +27,13 @@ fn format_validators() -> &'static [FormatChecker] { | ||
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,7 +195,7 @@ pub fn validation_error_response(err: &ValidationError) -> Response<ResponseBody | ||
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,4 +519,60 @@ mod tests { | ||
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,7 +72,7 @@ pub fn xml_response(status: StatusCode, body: &str) -> Response<ResponseBody> { | ||
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,10 +238,13 @@ fn format_validators() -> &'static HashMap<String, Arc<FormatChecker>> { | ||
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,8 +1856,13 @@ impl JustAPIApp { | ||
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,7 +173,14 @@ pub(crate) fn call_python_handler( | ||
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,17 +330,27 @@ pub(crate) fn fast_dumps(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> { | ||
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 | |