1#![deny(missing_docs)]
19#![deny(rustdoc::broken_intra_doc_links)]
20
21pub mod error;
22pub mod middleware;
23
24use std::collections::BTreeMap;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27use std::time::Duration;
28
29use af_context::{PlatformContextProvider, RequestContext, RequestMetadata};
30use axum::extract::{DefaultBodyLimit, State};
31use axum::http::HeaderValue;
32use axum::routing::get;
33use axum::{Json, Router};
34use serde_json::{json, Value};
35use tokio::task::JoinHandle;
36use tokio_util::sync::CancellationToken;
37use tonic::{Request, Status};
38use tower::limit::ConcurrencyLimitLayer;
39use tower_http::cors::{AllowOrigin, CorsLayer};
40use tower_http::timeout::TimeoutLayer;
41use tower_http::trace::TraceLayer;
42
43pub use af_context::RequestId;
44pub use error::ApiError;
45pub use middleware::{latency_slo, rate_limit, request_id, RateLimiter, SLO_BUDGET_MS};
46
47#[derive(Clone)]
49pub struct EdgeConfig {
50 pub allowed_origins: Vec<HeaderValue>,
52 pub max_body_bytes: usize,
54 pub request_timeout: Duration,
56 pub max_concurrency: usize,
58 pub requests_per_window: u64,
60 pub rate_window: Duration,
62}
63
64impl Default for EdgeConfig {
65 fn default() -> Self {
66 Self {
67 allowed_origins: Vec::new(),
68 max_body_bytes: 1024 * 1024,
69 request_timeout: Duration::from_secs(30),
70 max_concurrency: 128,
71 requests_per_window: 120,
72 rate_window: Duration::from_secs(1),
73 }
74 }
75}
76
77#[derive(Clone, Default)]
79pub struct Readiness(Arc<AtomicBool>);
80
81impl Readiness {
82 pub fn set(&self, ready: bool) {
84 self.0.store(ready, Ordering::Release);
85 }
86
87 pub fn is_ready(&self) -> bool {
89 self.0.load(Ordering::Acquire)
90 }
91}
92
93pub async fn health() -> Json<Value> {
95 Json(json!({ "status": "ok" }))
96}
97
98pub async fn liveness() -> Json<Value> {
100 health().await
101}
102
103pub async fn readiness(State(readiness): State<Readiness>) -> impl axum::response::IntoResponse {
105 let status = if readiness.is_ready() {
106 axum::http::StatusCode::OK
107 } else {
108 axum::http::StatusCode::SERVICE_UNAVAILABLE
109 };
110 (
111 status,
112 Json(json!({ "status": if readiness.is_ready() { "ready" } else { "not_ready" } })),
113 )
114}
115
116pub async fn metrics() -> ([(&'static str, &'static str); 1], &'static str) {
119 (
120 [("content-type", "text/plain; version=0.0.4")],
121 "agent_factory_up 1\n",
122 )
123}
124
125pub fn with_standard_middleware<S>(router: Router<S>) -> Router<S>
128where
129 S: Clone + Send + Sync + 'static,
130{
131 router
132 .layer(axum::middleware::from_fn(latency_slo))
133 .layer(axum::middleware::from_fn(request_id))
134}
135
136pub fn with_edge_middleware(router: Router, config: EdgeConfig) -> Router {
138 let cors = if config.allowed_origins.is_empty() {
139 CorsLayer::new()
140 } else {
141 CorsLayer::new().allow_origin(AllowOrigin::list(config.allowed_origins))
142 };
143 let limiter = RateLimiter::new(config.requests_per_window, config.rate_window);
144 with_standard_middleware(router)
145 .layer(TraceLayer::new_for_http())
146 .layer(ConcurrencyLimitLayer::new(config.max_concurrency.max(1)))
147 .layer(TimeoutLayer::with_status_code(
148 axum::http::StatusCode::REQUEST_TIMEOUT,
149 config.request_timeout,
150 ))
151 .layer(DefaultBodyLimit::max(config.max_body_bytes))
152 .layer(axum::middleware::from_fn_with_state(limiter, rate_limit))
153 .layer(cors)
154}
155
156pub fn base_router<S>() -> Router<S>
158where
159 S: Clone + Send + Sync + 'static,
160{
161 Router::new()
162 .route("/health", get(health))
163 .route("/metrics", get(metrics))
164}
165
166pub fn operations_router(state: Readiness) -> Router {
168 Router::new()
169 .route("/health", get(health))
170 .route("/live", get(liveness))
171 .route("/ready", get(readiness))
172 .route("/metrics", get(metrics))
173 .with_state(state)
174}
175
176pub async fn resolve_tonic_context<T>(
178 provider: &dyn PlatformContextProvider,
179 request: &Request<T>,
180) -> Result<RequestContext, Status> {
181 let metadata = request
182 .metadata()
183 .iter()
184 .filter_map(|entry| match entry {
185 tonic::metadata::KeyAndValueRef::Ascii(key, value) => value
186 .to_str()
187 .ok()
188 .map(|value| (key.as_str().to_string(), value.to_string())),
189 tonic::metadata::KeyAndValueRef::Binary(_, _) => None,
190 })
191 .collect::<BTreeMap<_, _>>();
192 provider
193 .resolve(&RequestMetadata(metadata))
194 .await
195 .map_err(|error| Status::unauthenticated(error.to_string()))
196}
197
198pub async fn shutdown_signal(cancel: CancellationToken) {
200 #[cfg(unix)]
201 {
202 if let Ok(mut terminate) =
203 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
204 {
205 tokio::select! {
206 _ = tokio::signal::ctrl_c() => {}
207 _ = terminate.recv() => {}
208 }
209 } else {
210 let _ = tokio::signal::ctrl_c().await;
211 }
212 }
213 #[cfg(not(unix))]
214 let _ = tokio::signal::ctrl_c().await;
215 cancel.cancel();
216}
217
218pub async fn join_tasks(tasks: Vec<JoinHandle<()>>, grace: Duration) -> bool {
220 let aborts: Vec<_> = tasks.iter().map(JoinHandle::abort_handle).collect();
221 let joined = tokio::time::timeout(grace, futures_join(tasks))
222 .await
223 .unwrap_or(false);
224 if !joined {
225 for task in aborts {
226 task.abort();
227 }
228 }
229 joined
230}
231
232pub async fn supervise_tasks(
235 mut tasks: Vec<JoinHandle<()>>,
236 readiness: Readiness,
237 cancel: CancellationToken,
238 grace: Duration,
239) -> bool {
240 use std::future::Future;
241 let requested = tokio::select! {
242 biased;
243 _ = cancel.cancelled() => true,
244 (index, outcome) = std::future::poll_fn(|cx| {
245 for (index, task) in tasks.iter_mut().enumerate() {
246 if let std::task::Poll::Ready(outcome) = std::pin::Pin::new(task).poll(cx) {
247 return std::task::Poll::Ready((index, outcome));
248 }
249 }
250 std::task::Poll::Pending
251 }) => {
252 drop(tasks.swap_remove(index));
253 tracing::error!(exit_reason = if outcome.is_err() { "task_panicked" } else { "task_exited" }, "host worker stopped unexpectedly");
254 false
255 }
256 };
257 readiness.set(false);
258 cancel.cancel();
259 let joined = join_tasks(tasks, grace).await;
260 tracing::info!(
261 exit_reason = if requested && joined {
262 "shutdown"
263 } else {
264 "worker_failure"
265 },
266 "host stopped"
267 );
268 requested && joined
269}
270
271async fn futures_join(tasks: Vec<JoinHandle<()>>) -> bool {
272 for task in tasks {
273 if task.await.is_err() {
274 return false;
275 }
276 }
277 true
278}