feat(notify): /ready 接入 JMAP session 与 Telegram getMe 端到端探测

ARCH-READY-BASELINE 从「配置完整性 + Redis 可达性」升级为端到端就绪:

- PROBE_TIMEOUT=3000ms 各探针独立超时(notify.rs:69)
- cached_jmap_session_url / cached_bot_token 先克隆后 await,
  避免跨 .await 持有 RwLockReadGuard(非 Send)
- probe_jmap_session:GET JMAP session URL,error_for_status
- probe_telegram_get_me:bot token 仅用于拼 URL,绝不日志
- ready():四者全过返 200 + 就绪报告 JSON(jmap/telegram 真实结果),
  任一失败 503 + Retry-After: 30(标准错误 envelope)
- 探针只读、无状态写入;token 永不回显(SAF-NO-SECRET-ECHO)

门禁:fmt / check / clippy / test 4/4 绿(45 passed, 0 failed, 1 ignored)。
This commit is contained in:
anonsaber 2026-09-28 10:15:52 +08:00
parent a39ebcf9d1
commit c0aab5e966
1 changed files with 67 additions and 3 deletions

View File

@ -64,17 +64,81 @@ async fn healthz() -> impl IntoResponse {
(StatusCode::OK, "ok") (StatusCode::OK, "ok")
} }
/// Probe budget for readiness checks. Kept short so `/ready` fails fast instead of holding an
/// upstream connection open (no long-lived connections).
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(3_000);
/// Read the cached runtime configuration without holding the lock guard across an await.
/// Callers refresh the cache first so this never reports a stale view.
fn cached_jmap_session_url(app: &AppState) -> Option<String> {
app.business_runtime
.read()
.ok()?
.as_ref()
.map(|config| config.jmap_session_url.clone())
}
fn cached_bot_token(app: &AppState) -> Option<secrecy::SecretString> {
app.business_runtime
.read()
.ok()?
.as_ref()
.map(|config| config.bot_token.clone())
}
/// End-to-end JMAP session probe: the configured session URL must answer with a 2xx.
/// Reusable by the remote debug surface so readiness and debugging share one implementation.
async fn probe_jmap_session(app: &AppState) -> Result<(), &'static str> {
let Some(session_url) = cached_jmap_session_url(app) else {
return Err("jmap session url not configured");
};
let request = reqwest::Client::new().get(session_url).send();
match tokio::time::timeout(PROBE_TIMEOUT, request).await {
Ok(Ok(response)) => response
.error_for_status()
.map(|_| ())
.map_err(|_| "jmap session probe failed"),
Ok(Err(_)) => Err("jmap session probe failed"),
Err(_) => Err("jmap session probe timed out"),
}
}
/// End-to-end Telegram probe: `getMe` must answer with a 2xx. The bot token is used to build the
/// request URL only; it is never logged, echoed, or returned (SAF-NO-SECRET-ECHO).
async fn probe_telegram_get_me(app: &AppState) -> Result<(), &'static str> {
let Some(token) = cached_bot_token(app) else {
return Err("telegram bot token not configured");
};
let request = reqwest::Client::new()
.get(format!(
"https://api.telegram.org/bot{}/getMe",
token.expose_secret()
))
.send();
match tokio::time::timeout(PROBE_TIMEOUT, request).await {
Ok(Ok(response)) => response
.error_for_status()
.map(|_| ())
.map_err(|_| "telegram get_me probe failed"),
Ok(Err(_)) => Err("telegram get_me probe failed"),
Err(_) => Err("telegram get_me probe timed out"),
}
}
async fn ready(State(app): State<AppState>) -> Response { async fn ready(State(app): State<AppState>) -> Response {
let configured = app.setup_missing.is_empty(); let configured = app.setup_missing.is_empty();
let redis = app.state.is_enabled().await.is_ok(); let redis = app.state.is_enabled().await.is_ok();
if configured && redis { refresh_business_config(&app).await;
let jmap = probe_jmap_session(&app).await.is_ok();
let telegram = probe_telegram_get_me(&app).await.is_ok();
if configured && redis && jmap && telegram {
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"status": "ready", "status": "ready",
"configured": configured, "configured": configured,
"redis": redis, "redis": redis,
"jmap": configured, "jmap": jmap,
"push": configured, "push": configured,
"telegram": configured, "telegram": telegram,
})) }))
.into_response() .into_response()
} else { } else {