Skip to main content

af_web/
lib.rs

1//! Reusable HTTP/gRPC host infrastructure for Agent Factory services.
2//!
3//! - [`ApiError`] — JSON error envelope carrying the request id.
4//! - [`middleware::request_id`] / [`middleware::latency_slo`] — request-id
5//!   propagation and the API latency SLO (warn past [`middleware::SLO_BUDGET_MS`]).
6//! - [`with_standard_middleware`] — wrap any `Router` with the standard stack.
7//! - [`health`] — a ready-made health handler.
8//! - [`metrics`] — a minimal Prometheus-compatible liveness metric.
9//!
10//! A service composes its product routes and wraps them:
11//!
12//! ```ignore
13//! let app = with_standard_middleware(
14//!     Router::new().route("/health", get(health)).merge(product_routes),
15//! );
16//! ```
17
18#![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/// Production edge limits shared by reference hosts.
48#[derive(Clone)]
49pub struct EdgeConfig {
50    /// Explicit allowed origins. Empty denies cross-origin requests.
51    pub allowed_origins: Vec<HeaderValue>,
52    /// Maximum request body bytes.
53    pub max_body_bytes: usize,
54    /// Unary request timeout.
55    pub request_timeout: Duration,
56    /// Maximum in-flight requests.
57    pub max_concurrency: usize,
58    /// Maximum requests in each rate window.
59    pub requests_per_window: u64,
60    /// Rate-limit window.
61    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/// Mutable readiness signal; liveness remains independent.
78#[derive(Clone, Default)]
79pub struct Readiness(Arc<AtomicBool>);
80
81impl Readiness {
82    /// Changes whether the host can accept traffic.
83    pub fn set(&self, ready: bool) {
84        self.0.store(ready, Ordering::Release);
85    }
86
87    /// Returns the current readiness state.
88    pub fn is_ready(&self) -> bool {
89        self.0.load(Ordering::Acquire)
90    }
91}
92
93/// Standard health endpoint payload.
94pub async fn health() -> Json<Value> {
95    Json(json!({ "status": "ok" }))
96}
97
98/// Liveness endpoint; success means the process event loop is responsive.
99pub async fn liveness() -> Json<Value> {
100    health().await
101}
102
103/// Readiness endpoint backed by the host's dependency state.
104pub 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
116/// Baseline metric shared by reference hosts. Products merge their own metric
117/// families into the same endpoint when they need richer observability.
118pub 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
125/// Wrap a router with the standard middleware stack: latency SLO (outermost,
126/// so it times everything) over request-id (so the id exists for SLO logs).
127pub 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
136/// Applies bounded production edge controls. CORS is deny-by-default.
137pub 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
156/// A `Router` preseeded with `GET /health`. Services merge their routes onto it.
157pub 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
166/// Router exposing separate process liveness and dependency readiness.
167pub 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
176/// Resolves verified caller context from tonic metadata for every gRPC service.
177pub 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
198/// Waits for SIGINT/SIGTERM and cancels all host tasks.
199pub 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
218/// Joins background tasks within a bounded shutdown grace period.
219pub 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
232/// Fails readiness and cancels sibling workers if any host task exits early.
233/// Returns true only for a requested shutdown with all workers drained.
234pub 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}