Files
clawmates/crates/cm-api/src/error.rs
T
Omar SobhandClaude Opus 5 deed591da6 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]>
2026-08-08 15:50:29 -07:00

74 lines
2.3 KiB
Rust

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// API-surface errors with their HTTP mapping. Internal causes are logged
/// server-side, never echoed to clients.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("bad request")]
BadRequest,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("conflict")]
Conflict,
#[error("{0}")]
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")]
Internal,
}
impl From<cm_db::DbError> for ApiError {
fn from(err: cm_db::DbError) -> Self {
match err {
cm_db::DbError::NotFound => ApiError::NotFound,
_ => ApiError::Internal,
}
}
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
ApiError::from(cm_db::DbError::from(err))
}
}
impl From<cm_auth::AuthError> for ApiError {
fn from(err: cm_auth::AuthError) -> Self {
match err {
cm_auth::AuthError::InvalidCredentials | cm_auth::AuthError::Unauthenticated => {
ApiError::Unauthorized
}
_ => ApiError::Internal,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
ApiError::BadRequest => StatusCode::BAD_REQUEST,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Conflict => StatusCode::CONFLICT,
ApiError::Quota(_) => StatusCode::PAYMENT_REQUIRED,
ApiError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}