Skip to main content

ferra_agent/
api.rs

1use std::sync::atomic::Ordering;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use axum::{
6    extract::{Path, Query, State},
7    http::{HeaderMap, HeaderValue, StatusCode},
8    response::{IntoResponse, Response},
9    routing::get,
10    Json, Router,
11};
12use serde::Deserialize;
13use serde_json::{json, Value};
14use tower_http::trace::TraceLayer;
15
16use crate::cache::PrefixState;
17use crate::upstream::UpstreamClient;
18
19pub struct AgentState {
20    pub upstream: UpstreamClient,
21    pub prefixes: Vec<Arc<PrefixState>>,
22}
23
24impl AgentState {
25    pub fn find_prefix(&self, key: &str) -> Option<&Arc<PrefixState>> {
26        // Linear scan; prefix counts are typically tens, not thousands.
27        // Pick the longest matching prefix so `/cfg/services/payment/foo`
28        // prefers `services/payment/` over `services/`.
29        self.prefixes
30            .iter()
31            .filter(|p| key.starts_with(&p.prefix))
32            .max_by_key(|p| p.prefix.len())
33    }
34
35    pub fn find_prefix_for_listing<'a>(&'a self, prefix: &str) -> Option<&'a Arc<PrefixState>> {
36        // For list queries the *requested* prefix must equal or extend one of
37        // our watched prefixes; we then filter the relevant cache subtree.
38        self.prefixes
39            .iter()
40            .filter(|p| prefix.starts_with(&p.prefix))
41            .max_by_key(|p| p.prefix.len())
42    }
43
44    pub fn all_ready(&self) -> bool {
45        self.prefixes
46            .iter()
47            .all(|p| p.ready.load(Ordering::Relaxed))
48    }
49}
50
51pub fn router(state: Arc<AgentState>) -> Router {
52    Router::new()
53        .route("/healthz", get(healthz))
54        .route("/readyz", get(readyz))
55        .route("/cfg", get(list_handler))
56        .route("/cfg/*key", get(cfg_handler))
57        .with_state(state)
58        .layer(TraceLayer::new_for_http())
59}
60
61async fn healthz() -> impl IntoResponse {
62    (StatusCode::OK, Json(json!({"status": "ok"})))
63}
64
65async fn readyz(State(state): State<Arc<AgentState>>) -> impl IntoResponse {
66    if state.all_ready() {
67        (StatusCode::OK, Json(json!({"status": "ok"})))
68    } else {
69        (
70            StatusCode::SERVICE_UNAVAILABLE,
71            Json(json!({"status": "loading"})),
72        )
73    }
74}
75
76#[derive(Deserialize)]
77struct CfgQuery {
78    /// If set, hold the request for at most this long while waiting for
79    /// `key`'s `event_id` to exceed `since`. Parsed via `humantime`,
80    /// e.g. `30s`, `1m`, `500ms`. Capped at 5 minutes.
81    wait: Option<String>,
82    /// Cursor — return immediately when key's `event_id > since`.
83    #[serde(default)]
84    since: i64,
85}
86
87async fn cfg_handler(
88    State(state): State<Arc<AgentState>>,
89    Path(key): Path<String>,
90    Query(q): Query<CfgQuery>,
91) -> Response {
92    let Some(prefix) = state.find_prefix(&key) else {
93        return error_response(
94            StatusCode::NOT_FOUND,
95            "key_not_in_watched_prefix",
96            &format!("no watched prefix matches key {:?}", key),
97        );
98    };
99
100    let wait = q.wait.as_deref().and_then(parse_wait);
101
102    match wait {
103        None => immediate_response(prefix, &key),
104        Some(wait) => long_poll(prefix.clone(), key, q.since, wait).await,
105    }
106}
107
108fn immediate_response(prefix: &PrefixState, key: &str) -> Response {
109    match prefix.get(key) {
110        Some(entry) => value_response(&entry.value, entry.event_id),
111        None => error_response(StatusCode::NOT_FOUND, "not_found", "key not in cache"),
112    }
113}
114
115async fn long_poll(prefix: Arc<PrefixState>, key: String, since: i64, wait: Duration) -> Response {
116    let deadline = Instant::now() + wait;
117
118    loop {
119        let cur = prefix.get(&key);
120        let cur_id = cur.as_ref().map(|e| e.event_id).unwrap_or(0);
121        if cur_id > since {
122            // Value (or absence) has advanced past `since`. Return now.
123            return match cur {
124                Some(e) => value_response(&e.value, e.event_id),
125                None => {
126                    // Key was deleted; surface as 404 with the prefix's
127                    // current cursor so the caller can advance their `since`.
128                    error_response_with_index(
129                        StatusCode::NOT_FOUND,
130                        "not_found",
131                        "key not in cache",
132                        prefix.latest_event_id.load(Ordering::Relaxed),
133                    )
134                }
135            };
136        }
137
138        let now = Instant::now();
139        if now >= deadline {
140            // Timed out without seeing this key change.
141            return match cur {
142                Some(e) => value_response(&e.value, e.event_id),
143                None => error_response_with_index(
144                    StatusCode::NOT_FOUND,
145                    "not_found",
146                    "key not in cache",
147                    prefix.latest_event_id.load(Ordering::Relaxed),
148                ),
149            };
150        }
151        let remaining = deadline - now;
152
153        let mut rx = prefix.notify.subscribe();
154        match tokio::time::timeout(remaining, rx.recv()).await {
155            Ok(_) => {
156                // Got a notification (Ok) or the broadcast lagged (Err);
157                // either way re-check.
158                continue;
159            }
160            Err(_) => {
161                // Timed out — fall through and return the current state.
162                continue;
163            }
164        }
165    }
166}
167
168fn parse_wait(s: &str) -> Option<Duration> {
169    let d = humantime::parse_duration(s).ok()?;
170    Some(d.min(Duration::from_secs(5 * 60)))
171}
172
173#[derive(Deserialize)]
174struct ListQuery {
175    prefix: String,
176}
177
178async fn list_handler(
179    State(state): State<Arc<AgentState>>,
180    Query(q): Query<ListQuery>,
181) -> Response {
182    let Some(prefix_state) = state.find_prefix_for_listing(&q.prefix) else {
183        return error_response(
184            StatusCode::NOT_FOUND,
185            "prefix_not_watched",
186            &format!("requested prefix {:?} is not under any watched prefix", q.prefix),
187        );
188    };
189
190    let items: Vec<_> = prefix_state
191        .list()
192        .into_iter()
193        .filter(|(k, _)| k.starts_with(&q.prefix))
194        .map(|(k, e)| {
195            json!({
196                "key": k,
197                "value": e.value,
198                "event_id": e.event_id,
199            })
200        })
201        .collect();
202
203    let body = json!({
204        "prefix": q.prefix,
205        "latest_event_id": prefix_state.latest_event_id.load(Ordering::Relaxed),
206        "items": items,
207    });
208    let mut headers = HeaderMap::new();
209    headers.insert(
210        "X-Ferra-Index",
211        HeaderValue::from_str(
212            &prefix_state
213                .latest_event_id
214                .load(Ordering::Relaxed)
215                .to_string(),
216        )
217        .unwrap(),
218    );
219    (StatusCode::OK, headers, Json(body)).into_response()
220}
221
222fn value_response(value: &Value, event_id: i64) -> Response {
223    let mut headers = HeaderMap::new();
224    headers.insert(
225        "X-Ferra-Index",
226        HeaderValue::from_str(&event_id.to_string()).unwrap(),
227    );
228    (StatusCode::OK, headers, Json(value.clone())).into_response()
229}
230
231fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
232    (
233        status,
234        Json(json!({
235            "error": code,
236            "message": message,
237        })),
238    )
239        .into_response()
240}
241
242fn error_response_with_index(
243    status: StatusCode,
244    code: &str,
245    message: &str,
246    event_id: i64,
247) -> Response {
248    let mut headers = HeaderMap::new();
249    headers.insert(
250        "X-Ferra-Index",
251        HeaderValue::from_str(&event_id.to_string()).unwrap(),
252    );
253    (
254        status,
255        headers,
256        Json(json!({
257            "error": code,
258            "message": message,
259        })),
260    )
261        .into_response()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn parse_wait_caps_at_5min() {
270        assert_eq!(parse_wait("30s"), Some(Duration::from_secs(30)));
271        assert_eq!(parse_wait("10m"), Some(Duration::from_secs(5 * 60)));
272        assert_eq!(parse_wait("garbage"), None);
273    }
274
275    #[test]
276    fn find_prefix_picks_longest_match() {
277        let upstream = UpstreamClient::new("http://x".into());
278        let s = AgentState {
279            upstream,
280            prefixes: vec![
281                Arc::new(PrefixState::new("services/".into())),
282                Arc::new(PrefixState::new("services/payment/".into())),
283            ],
284        };
285        let p = s.find_prefix("services/payment/timeout_ms").unwrap();
286        assert_eq!(p.prefix, "services/payment/");
287        let p = s.find_prefix("services/auth/jwt").unwrap();
288        assert_eq!(p.prefix, "services/");
289        assert!(s.find_prefix("flags/x").is_none());
290    }
291}