fix(roster): a rate-limited subscription is a 503 with a reason, not a 500

The retry landed and still failed: all four attempts returned 429. A bare
16-token probe with the same token, straight from gw-04, also returned 429
with `x-should-retry: true` — the Claude Code subscription itself is limited
right now, and no amount of backoff inside one HTTP request will outlast it.

So stop pretending it is a server bug. New `ApiError::Unavailable` → 503,
carrying the one sentence the operator can act on ("clears on its own; try
again shortly"), instead of an opaque `internal error` that sends them into
the logs. The harness now prints the response body rather than the generic
"the planner produced no usable proposal", which is what hid both walls —
first the credit balance, now this.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-08 15:50:29 -07:00
co-authored by Claude Opus 5
parent c3c4447810
commit deed591da6
4 changed files with 34 additions and 4 deletions
+8
View File
@@ -19,6 +19,13 @@ pub enum ApiError {
Conflict, Conflict,
#[error("{0}")] #[error("{0}")]
Quota(String), Quota(String),
/// A dependency is temporarily refusing work and will accept it later —
/// today, the Claude Code subscription's rate limit. Distinct from
/// `Internal` because the operator's next action is different: wait and
/// press the button again, rather than read a server log. A 500 with
/// "internal error" sent them looking for a bug that was not there.
#[error("{0}")]
Unavailable(String),
#[error("internal error")] #[error("internal error")]
Internal, Internal,
} }
@@ -58,6 +65,7 @@ impl IntoResponse for ApiError {
ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Conflict => StatusCode::CONFLICT, ApiError::Conflict => StatusCode::CONFLICT,
ApiError::Quota(_) => StatusCode::PAYMENT_REQUIRED, ApiError::Quota(_) => StatusCode::PAYMENT_REQUIRED,
ApiError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(status, Json(json!({ "error": self.to_string() }))).into_response() (status, Json(json!({ "error": self.to_string() }))).into_response()
+3 -1
View File
@@ -126,7 +126,9 @@ pub async fn suggest(
.await .await
.map_err(|e| { .map_err(|e| {
eprintln!("mission {id}: roster proposal failed: {e}"); eprintln!("mission {id}: roster proposal failed: {e}");
ApiError::Internal // A rate-limited subscription is a 503 the operator can act on, not
// a 500 that reads as "this server is broken".
crate::subscription::as_api_error(&e)
})?; })?;
// A model that answered with prose around its JSON has still answered; a // A model that answered with prose around its JSON has still answered; a
+17
View File
@@ -111,6 +111,23 @@ fn is_transient(e: &cm_llm::LlmError) -> bool {
} }
} }
/// Turn a `complete_or` failure into the right API error.
///
/// A rate limit that outlived the backoff is not a bug in this server, and
/// reporting it as one costs an operator a trip through the logs to find out
/// the answer was "wait". Measured: a bare 16-token probe with the same token
/// returned 429 with `x-should-retry: true` — Anthropic itself says try again.
pub fn as_api_error(err: &str) -> crate::error::ApiError {
if err.contains("rate_limit") || err.contains("429") {
return crate::error::ApiError::Unavailable(
"the Claude Code subscription is rate limited right now — this \
clears on its own; try again shortly"
.into(),
);
}
crate::error::ApiError::Internal
}
/// Stream one request and collect its text, waiting out transient failures. /// Stream one request and collect its text, waiting out transient failures.
async fn complete_with( async fn complete_with(
provider: &cm_llm::AnthropicProvider, provider: &cm_llm::AnthropicProvider,
+6 -3
View File
@@ -762,15 +762,18 @@ scenario_roster() {
|| { norun "roster: mission create failed"; return 1; } || { norun "roster: mission create failed"; return 1; }
info "roster: mission=$mission" info "roster: mission=$mission"
# 1. Ask the model. # 1. Ask the model. Keep the body: "no usable proposal" hid the actual
api "$token" POST "/api/missions/$mission/team-proposals" '{}' >/dev/null 2>&1 # reason (first a credit wall, then a subscription rate limit) behind a
# generic NORUN, and the reason is the only thing an operator can act on.
local ask
ask=$(api "$token" POST "/api/missions/$mission/team-proposals" '{}' 2>&1)
read -r proposal members <<<"$(api "$token" GET "/api/missions/$mission/team-proposals" | python3 -c ' read -r proposal members <<<"$(api "$token" GET "/api/missions/$mission/team-proposals" | python3 -c '
import json, sys import json, sys
d = json.load(sys.stdin) d = json.load(sys.stdin)
if d: print(d[0]["id"], len(d[0]["roster"]["members"])) if d: print(d[0]["id"], len(d[0]["roster"]["members"]))
' 2>/dev/null)" ' 2>/dev/null)"
if [ -z "${proposal:-}" ]; then if [ -z "${proposal:-}" ]; then
norun "roster: the planner produced no usable proposal" norun "roster: no usable proposal — the planner said: $(printf '%s' "$ask" | tr -d '\n' | cut -c1-200)"
return 1 return 1
fi fi
pass "roster: the planner sized this mission at $members member(s)" pass "roster: the planner sized this mission at $members member(s)"