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:
co-authored by
Claude Opus 5
parent
c3c4447810
commit
deed591da6
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user