Skip to main content

pagesift_api/
server.rs

1//! HTTP server routing: Firecrawl v1/v2 compatibility layers, Jina Reader adapter,
2//! health checks, and Prometheus metrics.
3
4use std::collections::HashMap;
5use std::future::Future;
6use std::pin::{Pin, pin};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::task::{Context, Poll};
10use std::time::{Duration, Instant};
11
12use axum::body::{Body, Bytes, HttpBody};
13use axum::extract::rejection::JsonRejection;
14use axum::extract::{Request, State};
15use axum::http::{HeaderMap, Method, StatusCode, header};
16use axum::middleware::{self, Next};
17use axum::response::{IntoResponse, Response};
18use axum::routing::get;
19use axum::{Json, Router};
20use http_body::{Frame, SizeHint};
21use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
22use hyper_util::server::conn::auto;
23use hyper_util::server::graceful::GracefulShutdown;
24use hyper_util::service::TowerToHyperService;
25use pagesift_store::Store;
26use tokio::net::TcpListener;
27use tokio::sync::Mutex;
28use tokio::time::Sleep;
29
30use crate::engine::Engine;
31use crate::error::{ApiError, ErrorCode};
32use crate::jobs::Jobs;
33use crate::limits::{Caller, KeyLimits, Limiter};
34use crate::metrics::Metrics;
35use crate::webhook::{WebhookConfig, WebhookSettings, Webhooks};
36
37/// Maximum documents per page when querying job status.
38pub const PAGE_SIZE: usize = 100;
39/// 批量抓取的并发:引擎的统一准入给出值时用它([`Engine::admitted_concurrency`]),
40/// `max_batch_concurrency` 配置是用户上限。两者都没有时用这个先验。
41/// [策略] 先验包 v0:0.81 的现值 8(准入接好以前的行为不变)。
42pub const DEFAULT_BATCH_CONCURRENCY: usize = 8;
43
44/// 服务端超时,防 slowloris 和挂死的请求。
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Timeouts {
47    /// 读完请求头的时限(HTTP/1),超时直接断开连接。只在 [`serve_with`] 里生效。
48    pub header_read: Duration,
49    /// 从请求进来到读完请求体的总时限,不是两次读之间的间隔:每 5 秒滴 1 字节也会超时。
50    pub body: Duration,
51    /// `/v2/parse` 上传文件的请求体时限(同样是总时限):50 MB 的文件 30 秒传完要 1.7 MB/s,
52    /// 上传单独放宽。
53    pub upload_body: Duration,
54    /// 整个请求(到响应头发出为止)的时限。要比同步 scrape 的 `timeout` 上限大,
55    /// crawl/batch 只是创建任务,很快返回,不受影响。
56    pub request: Duration,
57}
58
59impl Default for Timeouts {
60    fn default() -> Self {
61        Self {
62            header_read: Duration::from_secs(30),
63            body: Duration::from_secs(30),
64            upload_body: Duration::from_secs(120),
65            request: Duration::from_millis(crate::validate::MAX_SCRAPE_TIMEOUT_MS)
66                + Duration::from_secs(60),
67        }
68    }
69}
70
71#[derive(Clone, Default)]
72pub struct Config {
73    /// When empty, authentication is disabled; otherwise requires `Authorization: Bearer <key>`.
74    pub api_keys: Vec<String>,
75    /// Public URL for building absolute resource links.
76    pub public_url: Option<String>,
77    pub max_batch_concurrency: Option<usize>,
78    /// Persistent store for jobs and resume capability.
79    pub store: Option<Store>,
80    /// Per-key custom limits.
81    pub key_limits: HashMap<String, KeyLimits>,
82    /// Default limits applied to other keys or unauthenticated callers.
83    pub default_limits: KeyLimits,
84    pub webhook: WebhookSettings,
85    /// 请求体和整请求超时;请求头超时由 [`serve_with`] 的参数决定。
86    pub timeouts: Timeouts,
87    /// 多服务器网关:配额和每分钟请求数记在账本里(追加记录,崩溃重放、温备回放后一致)。
88    /// 单主机不设,照旧用本进程的令牌桶和存储里的当天用量。
89    pub ledger: Option<Arc<pagesift_cluster::Ledger>>,
90    /// 多服务器网关:任务存储的写入复制到温备([`crate::JobMirror`])。单主机不设。
91    pub job_mirror: Option<Arc<crate::JobMirror>>,
92    /// 追加在 `/metrics` 后面的指标(多服务器网关:按工人加标签的统计)。
93    pub extra_metrics: Option<ExtraMetrics>,
94    /// 监控调度者:多服务器时只有持有网关租约的机器调度(返回真才检查)。单主机不设,总是调度。
95    pub monitor_leader: Option<crate::monitor::Leader>,
96}
97
98/// 生成额外指标文本(Prometheus 文本格式)的回调。
99pub type ExtraMetrics = Arc<dyn Fn() -> String + Send + Sync>;
100
101impl std::fmt::Debug for Config {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("Config")
104            .field("api_keys", &self.api_keys.len())
105            .field("public_url", &self.public_url)
106            .field("max_batch_concurrency", &self.max_batch_concurrency)
107            .field("store", &self.store.is_some())
108            .field("key_limits", &self.key_limits.len())
109            .field("default_limits", &self.default_limits)
110            .field("webhook", &self.webhook)
111            .field("timeouts", &self.timeouts)
112            .field("ledger", &self.ledger.is_some())
113            .field("job_mirror", &self.job_mirror.is_some())
114            .field("extra_metrics", &self.extra_metrics.is_some())
115            .finish()
116    }
117}
118
119#[derive(Clone)]
120pub struct AppState {
121    pub engine: Arc<dyn Engine>,
122    pub keys: Arc<HashMap<String, Arc<KeyLimits>>>,
123    pub default_limits: Arc<KeyLimits>,
124    pub public_url: Option<String>,
125    /// 用户配置的批量并发上限(`max_batch_concurrency`),没配是 `None`。
126    pub batch_concurrency: Option<usize>,
127    pub jobs: Jobs,
128    pub limiter: Arc<Limiter>,
129    pub webhooks: Webhooks,
130    pub extracts: Arc<Mutex<HashMap<String, ExtractJob>>>,
131    pub agents: crate::agent::Agents,
132    pub(crate) monitors: crate::monitor::Monitors,
133    pub metrics: Arc<Metrics>,
134    pub extra_metrics: Option<ExtraMetrics>,
135}
136
137impl AppState {
138    /// 一个批量任务的并发:`min(请求的 maxConcurrency, 准入值, 用户上限)`。
139    /// 准入值来自引擎的统一准入([`Engine::admitted_concurrency`]);引擎还不给时,用户配了上限
140    /// 就用上限(和 0.81 一样),都没有时用先验 [`DEFAULT_BATCH_CONCURRENCY`]。
141    pub fn batch_concurrency_for(&self, requested: Option<usize>) -> usize {
142        batch_concurrency(
143            requested,
144            self.engine.admitted_concurrency(),
145            self.batch_concurrency,
146        )
147    }
148}
149
150fn batch_concurrency(
151    requested: Option<usize>,
152    admitted: Option<usize>,
153    cap: Option<usize>,
154) -> usize {
155    let admitted = admitted.unwrap_or(cap.unwrap_or(DEFAULT_BATCH_CONCURRENCY));
156    requested
157        .unwrap_or(admitted)
158        .min(admitted)
159        .min(cap.unwrap_or(usize::MAX))
160        .max(1)
161}
162
163pub struct ExtractJob {
164    pub status: &'static str,
165    pub(crate) owner: String,
166    pub data: Option<serde_json::Value>,
167    pub error: Option<ApiError>,
168    pub created: std::time::SystemTime,
169}
170
171/// Construct complete HTTP router with Firecrawl v1/v2, Jina Reader, and metrics.
172pub fn router(engine: Arc<dyn Engine>, config: Config) -> Router {
173    let timeouts = config.timeouts;
174    let default_limits = Arc::new(config.default_limits);
175    let mut keys: HashMap<String, Arc<KeyLimits>> = config
176        .api_keys
177        .into_iter()
178        .map(|k| (k, default_limits.clone()))
179        .collect();
180    keys.extend(config.key_limits.into_iter().map(|(k, l)| (k, Arc::new(l))));
181
182    let accounts: HashMap<String, Arc<KeyLimits>> = keys
183        .iter()
184        .map(|(k, l)| {
185            (
186                Caller::new(Some(k), l.clone()).account.to_string(),
187                l.clone(),
188            )
189        })
190        .collect();
191
192    let metrics = Arc::new(Metrics::default());
193    let limiter = Arc::new(match config.ledger {
194        Some(ledger) => {
195            // 所有账户的限额事先登记进账本(不在每个请求上登记);没有 key 的调用方是 anonymous
196            let mut all = accounts.clone();
197            all.entry("anonymous".into())
198                .or_insert_with(|| default_limits.clone());
199            Limiter::with_ledger(ledger, &all)
200        }
201        None => Limiter::new(config.store.clone()),
202    });
203    // 附件跟任务结果一样写进存储,重启后链接仍然有效(见 `attachments`)
204    if let Some(s) = &config.store
205        && tokio::runtime::Handle::try_current().is_ok()
206    {
207        crate::attachments::sweep_expired(crate::attachments::Disk {
208            store: s.clone(),
209            mirror: config.job_mirror.clone(),
210        });
211        crate::attachments::backfill_access(crate::attachments::Disk {
212            store: s.clone(),
213            mirror: config.job_mirror.clone(),
214        });
215    }
216    let mut webhooks = Webhooks::new(config.webhook, metrics.clone());
217    webhooks.mirror = config.job_mirror.clone();
218    let agents = crate::agent::Agents::new(
219        config.store.clone(),
220        Some(webhooks.clone()),
221        Some(limiter.clone()),
222    );
223    let jobs = Jobs::new(
224        config.store,
225        limiter.clone(),
226        Some(webhooks.clone()),
227        metrics.clone(),
228        config.job_mirror,
229    );
230
231    if tokio::runtime::Handle::try_current().is_ok() {
232        let fallback = default_limits.clone();
233        jobs.restore(&engine, &|account| {
234            accounts
235                .get(account)
236                .cloned()
237                .unwrap_or_else(|| fallback.clone())
238        });
239    } else {
240        tracing::warn!("router built outside a tokio runtime: saved jobs are not resumed");
241    }
242
243    let monitors = crate::monitor::Monitors {
244        accounts: Arc::new(accounts),
245        default_limits: default_limits.clone(),
246        ..Default::default()
247    };
248    let state = AppState {
249        engine: engine.clone(),
250        keys: Arc::new(keys),
251        default_limits,
252        public_url: config
253            .public_url
254            .map(|u| u.trim_end_matches('/').to_string()),
255        batch_concurrency: config.max_batch_concurrency,
256        jobs,
257        limiter,
258        webhooks,
259        extracts: Arc::default(),
260        agents,
261        monitors,
262        metrics,
263        extra_metrics: config.extra_metrics,
264    };
265
266    // Jina Reader 路由也会抓取页面,和 Firecrawl 路由一起放在鉴权、限流和配额之后
267    if tokio::runtime::Handle::try_current().is_ok() {
268        crate::monitor::start_scheduler(state.clone(), config.monitor_leader.clone());
269    }
270    let api = crate::firecrawl::firecrawl_routes()
271        .merge(crate::jina::jina_routes())
272        .merge(crate::monitor::routes())
273        .route_layer(middleware::from_fn_with_state(state.clone(), auth));
274
275    Router::new()
276        .route("/healthz", get(|| async { "ok" }))
277        .route("/metrics", get(render_metrics))
278        .merge(api)
279        .with_state(state.clone())
280        .fallback(|| async { ApiError::new(ErrorCode::NotFound, "no such endpoint") })
281        .layer(middleware::from_fn(uniform_errors))
282        .layer(middleware::from_fn_with_state(timeouts, deadline))
283        .layer(middleware::from_fn_with_state(state.clone(), observe))
284}
285
286/// 用默认超时在 `listener` 上提供服务,`shutdown` 完成后优雅退出。
287pub async fn serve(
288    listener: TcpListener,
289    app: Router,
290    shutdown: impl Future<Output = ()> + Send,
291) -> std::io::Result<()> {
292    serve_with(listener, app, Timeouts::default(), shutdown).await
293}
294
295/// accept 因资源耗尽出错后,没有连接结束时最多等多久再试。
296/// [策略] 0.81 的 1 秒作为兜底上限(axum::serve 同值):资源由别的进程释放时收不到通知,只能定时重试;
297/// 本进程的连接结束会提前叫醒(见 [`serve_with`])。
298const ACCEPT_RETRY_CAP: Duration = Duration::from_secs(1);
299
300/// 和 `axum::serve(..).with_graceful_shutdown(..)` 行为相同,另外给 HTTP/1 设了请求头读取超时
301/// (axum::serve 设不了):收到 `shutdown` 后停止 accept,通知每个连接优雅关闭,等它们都结束再返回。
302pub async fn serve_with(
303    listener: TcpListener,
304    app: Router,
305    timeouts: Timeouts,
306    shutdown: impl Future<Output = ()> + Send,
307) -> std::io::Result<()> {
308    let mut builder = auto::Builder::new(TokioExecutor::new());
309    builder
310        .http1()
311        .timer(TokioTimer::new())
312        .header_read_timeout(timeouts.header_read);
313    let graceful = GracefulShutdown::new();
314    let mut shutdown = pin!(shutdown);
315    // 有连接结束(释放了文件描述符)时叫醒因 accept 出错而在等的循环
316    let released = Arc::new(tokio::sync::Notify::new());
317    loop {
318        let stream = tokio::select! {
319            r = listener.accept() => match r {
320                Ok((stream, _)) => stream,
321                Err(e) => {
322                    // 和 axum::serve 一样:单个连接的错误忽略。其他错误多半是文件描述符或内核缓冲
323                    // 用完(EMFILE、ENFILE、ENOBUFS),要等资源释放:事件驱动,任一连接结束就重试
324                    // (它释放了描述符);没有连接结束时最多等 ACCEPT_RETRY_CAP 再试(别的进程释放的
325                    // 资源我们收不到通知)。取代 0.81 固定歇 1 秒:描述符一释放就接,不白等。
326                    if !matches!(
327                        e.kind(),
328                        std::io::ErrorKind::ConnectionRefused
329                            | std::io::ErrorKind::ConnectionAborted
330                            | std::io::ErrorKind::ConnectionReset
331                    ) {
332                        tracing::error!(error = %e, "accept failed");
333                        tokio::select! {
334                            () = released.notified() => {}
335                            () = tokio::time::sleep(ACCEPT_RETRY_CAP) => {}
336                            () = &mut shutdown => break,
337                        }
338                    }
339                    continue;
340                }
341            },
342            () = &mut shutdown => break,
343        };
344        // 响应头和分块正文分两次写时,Nagle 会让第二次写等对端的延迟确认(约 40 ms)
345        let _ = stream.set_nodelay(true);
346        let conn = builder
347            .serve_connection_with_upgrades(
348                TokioIo::new(stream),
349                TowerToHyperService::new(app.clone()),
350            )
351            .into_owned();
352        let conn = graceful.watch(conn);
353        let released = released.clone();
354        tokio::spawn(async move {
355            if let Err(e) = conn.await {
356                tracing::trace!(error = %e, "connection ended with an error");
357            }
358            released.notify_one();
359        });
360    }
361    drop(listener);
362    graceful.shutdown().await;
363    Ok(())
364}
365
366/// 请求体加总时限;整个请求加时限。超时都回统一的 JSON 错误(408 TIMEOUT)。
367async fn deadline(State(t): State<Timeouts>, req: Request, next: Next) -> Response {
368    let fired = Arc::new(AtomicBool::new(false));
369    // 上传放宽的请求体时间也加到整请求时限上,解析仍有完整的 `timeout` 可用
370    let (body_limit, request_limit) = if req.uri().path() == "/v2/parse" {
371        (
372            t.upload_body,
373            t.request + t.upload_body.saturating_sub(t.body),
374        )
375    } else {
376        (t.body, t.request)
377    };
378    let req = if req.body().is_end_stream() {
379        req
380    } else {
381        let fired = fired.clone();
382        req.map(|inner| {
383            Body::new(DeadlineBody {
384                inner,
385                sleep: Box::pin(tokio::time::sleep(body_limit)),
386                fired,
387            })
388        })
389    };
390    match tokio::time::timeout(request_limit, next.run(req)).await {
391        // 读请求体超时时提取器给的是 400,这里换成 408
392        Ok(_) if fired.load(Ordering::Relaxed) => ApiError::new(
393            ErrorCode::Timeout,
394            format!(
395                "request body was not received within {} s",
396                body_limit.as_secs_f64()
397            ),
398        )
399        .into_response(),
400        Ok(resp) => resp,
401        Err(_) => ApiError::new(
402            ErrorCode::Timeout,
403            format!(
404                "request did not complete within {} s",
405                request_limit.as_secs_f64()
406            ),
407        )
408        .into_response(),
409    }
410}
411
412/// 到点还没读完就报错的请求体。
413struct DeadlineBody {
414    inner: Body,
415    sleep: Pin<Box<Sleep>>,
416    fired: Arc<AtomicBool>,
417}
418
419impl HttpBody for DeadlineBody {
420    type Data = Bytes;
421    type Error = axum::Error;
422
423    fn poll_frame(
424        self: Pin<&mut Self>,
425        cx: &mut Context<'_>,
426    ) -> Poll<Option<Result<Frame<Bytes>, axum::Error>>> {
427        let this = self.get_mut();
428        if let Poll::Ready(frame) = Pin::new(&mut this.inner).poll_frame(cx) {
429            return Poll::Ready(frame);
430        }
431        if this.sleep.as_mut().poll(cx).is_ready() {
432            this.fired.store(true, Ordering::Relaxed);
433            return Poll::Ready(Some(Err(axum::Error::new("request body timed out"))));
434        }
435        Poll::Pending
436    }
437
438    fn is_end_stream(&self) -> bool {
439        self.inner.is_end_stream()
440    }
441
442    fn size_hint(&self) -> SizeHint {
443        self.inner.size_hint()
444    }
445}
446
447/// axum 自带的拒绝(查询参数、路径参数、JSON 解析失败、405 等)是纯文本或空 body,
448/// 统一换成 `{success: false, code, error}`,状态码和 `Allow` 等响应头不变。
449async fn uniform_errors(req: Request, next: Next) -> Response {
450    let resp = next.run(req).await;
451    let status = resp.status();
452    // 自己的错误都带 ErrorCode 扩展,不用动
453    if !status.is_client_error() || resp.extensions().get::<ErrorCode>().is_some() {
454        return resp;
455    }
456    let plain = resp
457        .headers()
458        .get(header::CONTENT_TYPE)
459        .is_none_or(|v| v.as_bytes().starts_with(b"text/plain"));
460    if !plain {
461        return resp;
462    }
463    let (parts, body) = resp.into_parts();
464    let text = axum::body::to_bytes(body, 64 * 1024)
465        .await
466        .map(|b| String::from_utf8_lossy(&b).trim().to_string())
467        .unwrap_or_default();
468    let code = match status {
469        StatusCode::NOT_FOUND => ErrorCode::NotFound,
470        StatusCode::PAYLOAD_TOO_LARGE => ErrorCode::TooLarge,
471        StatusCode::UNAUTHORIZED => ErrorCode::Unauthorized,
472        _ => ErrorCode::InvalidRequest,
473    };
474    let message = if text.is_empty() {
475        status
476            .canonical_reason()
477            .unwrap_or("bad request")
478            .to_lowercase()
479    } else {
480        text
481    };
482    let mut out = ApiError::new(code, message).into_response();
483    *out.status_mut() = status;
484    for (name, value) in &parts.headers {
485        if name != header::CONTENT_TYPE && name != header::CONTENT_LENGTH {
486            out.headers_mut().append(name, value.clone());
487        }
488    }
489    out
490}
491
492/// Authentication and rate limiting middleware.
493async fn auth(
494    State(s): State<AppState>,
495    headers: HeaderMap,
496    mut req: Request,
497    next: Next,
498) -> Response {
499    let caller = if s.keys.is_empty() {
500        Caller::new(None, s.default_limits.clone())
501    } else {
502        let key = headers
503            .get(header::AUTHORIZATION)
504            .and_then(|v| v.to_str().ok())
505            .and_then(|v| v.strip_prefix("Bearer "))
506            .map(str::trim);
507        match key.and_then(|k| s.keys.get(k).map(|l| (k, l))) {
508            Some((k, l)) => Caller::new(Some(k), l.clone()),
509            None => {
510                return ApiError::new(ErrorCode::Unauthorized, "missing or invalid API key")
511                    .into_response();
512            }
513        }
514    };
515    // 计入每分钟请求数的是会抓取的请求:POST,以及 Jina 的 `GET /r/{url}`、`GET /s/{query}`;轮询类 GET 不算
516    let path = req.uri().path();
517    if (req.method() == Method::POST || path.starts_with("/r/") || path.starts_with("/s/"))
518        && let Err(e) = s.limiter.check_rate(&caller)
519    {
520        let mut resp = e.into_response();
521        resp.extensions_mut().insert(caller);
522        return resp;
523    }
524    req.extensions_mut().insert(caller.clone());
525    let mut resp = next.run(req).await;
526    resp.extensions_mut().insert(caller);
527    resp
528}
529
530/// Metrics and structured request tracing middleware.
531async fn observe(State(s): State<AppState>, req: Request, next: Next) -> Response {
532    let route = req
533        .extensions()
534        .get::<axum::extract::MatchedPath>()
535        .map(|p| p.as_str().to_string())
536        .unwrap_or_else(|| "unmatched".into());
537    let method = req.method().clone();
538    let started = Instant::now();
539    s.metrics.request_started();
540    let resp = next.run(req).await;
541    let elapsed = started.elapsed();
542    let status = resp.status().as_u16();
543    s.metrics.request_finished(&route, status, elapsed);
544    let code = resp.extensions().get::<ErrorCode>().map(|c| c.as_str());
545    if let Some(code) = code {
546        s.metrics.error(code, "http");
547    }
548    let account = resp.extensions().get::<Caller>().map(|c| c.account.clone());
549    let latency_ms = elapsed.as_secs_f64() * 1000.0;
550    if route.starts_with("/v2/") || route.starts_with("/v1/") || route.starts_with("/r") {
551        tracing::info!(
552            target: "pagesift::http",
553            %method, %route, status, latency_ms, account = account.as_deref(), code,
554            "request"
555        );
556    } else {
557        tracing::debug!(target: "pagesift::http", %method, %route, status, latency_ms, "request");
558    }
559    resp
560}
561
562pub fn body<T>(r: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
563    r.map(|Json(v)| v)
564        .map_err(|e| ApiError::invalid(format!("invalid request body: {}", e.body_text())))
565}
566
567/// 兼容 Firecrawl 的请求体:照常解析(未知字段不报错),另外返回没人认的字段名——顶层的,以及
568/// `scrapeOptions` 里的(写成 `scrapeOptions.<名字>`)。调用方把它们写进 `warnings`(`ignored_fields`)。
569pub fn lenient_body<T: serde::de::DeserializeOwned>(
570    r: Result<Json<serde_json::Value>, JsonRejection>,
571    known: &[&[&str]],
572) -> Result<(T, Vec<String>), ApiError> {
573    use crate::fields;
574    let value = body(r)?;
575    let mut ignored = Vec::new();
576    if let Some(obj) = value.as_object() {
577        ignored = fields::unknown(obj, known);
578        if let Some(nested) = obj.get("scrapeOptions").and_then(|v| v.as_object()) {
579            let scrape = [
580                fields::names::<crate::types::ScrapeOptions>(),
581                fields::CLIENT_IDENTITY,
582            ];
583            ignored.extend(
584                fields::unknown(nested, &scrape)
585                    .into_iter()
586                    .map(|k| format!("scrapeOptions.{k}")),
587            );
588        }
589    }
590    let parsed = serde_json::from_value(value)
591        .map_err(|e| ApiError::invalid(format!("invalid request body: {e}")))?;
592    Ok((parsed, ignored))
593}
594
595pub fn base_url(s: &AppState, headers: &HeaderMap) -> String {
596    if let Some(u) = &s.public_url {
597        return u.clone();
598    }
599    let host = headers
600        .get(header::HOST)
601        .and_then(|v| v.to_str().ok())
602        .unwrap_or("localhost:3002");
603    format!("http://{host}")
604}
605
606pub fn webhook(
607    s: &AppState,
608    v: Option<&serde_json::Value>,
609) -> Result<Option<WebhookConfig>, ApiError> {
610    v.filter(|v| !v.is_null())
611        .map(|v| WebhookConfig::parse(v, s.webhooks.ssrf()))
612        .transpose()
613}
614
615async fn render_metrics(State(s): State<AppState>) -> impl IntoResponse {
616    let mut out = s.metrics.render(&s.jobs.counts().await);
617    if let Some(extra) = &s.extra_metrics {
618        out.push_str(&extra());
619    }
620    (
621        StatusCode::OK,
622        [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
623        out,
624    )
625}
626
627#[cfg(test)]
628mod batch_concurrency_tests {
629    use super::batch_concurrency;
630
631    /// 准入还没接(None)时和 0.81 完全一样:没配置是 min(请求, 8),配置了是 min(请求, 配置);
632    /// 准入给值以后按准入,用户配置仍是上限。
633    #[test]
634    fn admission_decides_and_the_user_caps() {
635        assert_eq!(batch_concurrency(None, None, None), 8);
636        assert_eq!(batch_concurrency(Some(20), None, None), 8);
637        assert_eq!(batch_concurrency(Some(3), None, None), 3);
638        assert_eq!(batch_concurrency(None, None, Some(32)), 32);
639        assert_eq!(batch_concurrency(Some(64), None, Some(32)), 32);
640        assert_eq!(batch_concurrency(None, Some(50), None), 50);
641        assert_eq!(batch_concurrency(None, Some(50), Some(32)), 32);
642        assert_eq!(batch_concurrency(Some(5), Some(50), None), 5);
643        assert_eq!(batch_concurrency(Some(0), Some(0), None), 1);
644    }
645}