server: emit Retry-After header on 429 / 503 responses

Closes the doc-vs-code gap at api.rs:343 and lib.rs:344-355: the
documentation claims `Retry-After` is set on TooManyRequests /
ServiceUnavailable responses, but `IntoResponse for ApiError`
emitted only `(StatusCode, Json(ErrorOutput))` — no header.

Wires a constant `RETRY_AFTER_SECONDS = "60"` for both 429 and 503
codes. Plumbing per-RejectReason durations through is a follow-up;
the admission rejects we surface today recover bounded by request
handler duration rather than calendar wait, so a constant suffices.

Pinned by `ingest_per_actor_admission_cap_returns_429`. Test now
fully green: 1+ of 8 concurrent /ingest under cap=1 receives 429
with Retry-After: 60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-08 16:58:47 +02:00
parent 05a8bd5de1
commit c745dd69ae
No known key found for this signature in database

View file

@ -467,10 +467,29 @@ fn summarize_merge_conflicts(conflicts: &[api::MergeConflictOutput]) -> String {
format!("merge conflicts: {}{}", preview.join("; "), suffix)
}
/// Constant `Retry-After` value (seconds) emitted on 429 / 503 responses.
/// Matches the doc claim at `ApiError::too_many_requests` and
/// `ApiError::service_unavailable`. Plumbing per-RejectReason durations
/// through is a follow-up; the admission rejects we surface today are
/// uniformly bounded by the in-flight cap recovery time, which is
/// dominated by request handler duration rather than calendar wait.
const RETRY_AFTER_SECONDS: &str = "60";
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut headers = axum::http::HeaderMap::new();
if matches!(
self.code,
ErrorCode::TooManyRequests | ErrorCode::ServiceUnavailable
) {
headers.insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static(RETRY_AFTER_SECONDS),
);
}
(
self.status,
headers,
Json(ErrorOutput {
error: self.message,
code: Some(self.code),