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]>
74 lines
2.3 KiB
Rust
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()
|
|
}
|
|
}
|