fix(#2175): return 409/400 not 500 for turn-in-flight + validation errors

This commit is contained in:
damocles 2026-07-04 12:42:39 +02:00 committed by mara
commit c708f698ee

View file

@ -531,11 +531,29 @@ async fn api_loose_ends(State(state): State<AppState>) -> Response {
{
Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends,
Ok(Ok(hive_sh4re::Response::Err { message })) => {
return error_response(&format!("get_loose_ends: {message}"));
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("get_loose_ends: {message}"),
);
}
Ok(Ok(other)) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("unexpected response: {other:?}"),
);
}
Ok(Err(e)) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("transport: {e:#}"),
);
}
Err(_) => {
return error_response(
StatusCode::CONFLICT,
"get_loose_ends: timed out — hive-c0re busy, retry",
);
}
Ok(Ok(other)) => return error_response(&format!("unexpected response: {other:?}")),
Ok(Err(e)) => return error_response(&format!("transport: {e:#}")),
Err(_) => return error_response("get_loose_ends: timed out — hive-c0re busy, retry"),
};
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
}
@ -877,7 +895,7 @@ struct SendForm {
async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response {
let body = form.body.trim().to_owned();
if body.is_empty() {
return error_response("send: `body` required");
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
}
let result = match tokio::time::timeout(
SOCKET_FETCH_TIMEOUT,
@ -902,7 +920,10 @@ async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) ->
// inbox row gets consumed by the time `TurnEnd` fires the
// existing turn-end refresh.
Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("send failed: {e}")),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("send failed: {e}"),
),
}
}
@ -984,7 +1005,10 @@ async fn post_login_start(State(state): State<AppState>) -> Response {
state.bus.emit_status("needs_login_in_progress");
(axum::http::StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("login start failed: {e:#}")),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("login start failed: {e:#}"),
),
}
}
@ -996,10 +1020,13 @@ struct CodeForm {
async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
let session = state.session.lock().unwrap().clone();
let Some(session) = session else {
return error_response("no login session running");
return error_response(StatusCode::CONFLICT, "no login session running");
};
if let Err(e) = session.submit_code(&form.code).await {
return error_response(&format!("submit code failed: {e:#}"));
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("submit code failed: {e:#}"),
);
}
(axum::http::StatusCode::OK, "ok").into_response()
}
@ -1035,7 +1062,7 @@ struct ModelForm {
async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelForm>) -> Response {
let name = form.model.trim();
if name.is_empty() {
return error_response("model: name required");
return error_response(StatusCode::BAD_REQUEST, "model: name required");
}
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note {
@ -1059,10 +1086,13 @@ struct EffortForm {
async fn post_set_effort(State(state): State<AppState>, Form(form): Form<EffortForm>) -> Response {
let level = form.effort.trim();
if !crate::events::is_valid_effort(level) {
return error_response(&format!(
"effort: level must be one of {}",
crate::events::EFFORT_LEVELS.join(", ")
));
return error_response(
StatusCode::BAD_REQUEST,
&format!(
"effort: level must be one of {}",
crate::events::EFFORT_LEVELS.join(", ")
),
);
}
state.bus.set_effort(level);
state.bus.emit(crate::events::LiveEvent::Note {
@ -1079,7 +1109,10 @@ async fn post_compact(State(state): State<AppState>) -> Response {
// Reject immediately if a normal turn is in flight — concurrent access
// to the claude session is unsafe and produces garbled output.
let Ok(guard) = lock.try_lock_owned() else {
return error_response("turn in flight — wait for it to finish before compacting");
return error_response(
StatusCode::CONFLICT,
"turn in flight — wait for it to finish before compacting",
);
};
let bus = state.bus.clone();
let files = state.files.clone();
@ -1211,10 +1244,13 @@ async fn post_cancel_turn(State(state): State<AppState>) -> Response {
(axum::http::StatusCode::OK, "ok").into_response()
}
fn error_response(message: &str) -> Response {
fn error_response(status: StatusCode, message: &str) -> Response {
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
// be noise.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
// be noise. Status is per-caller: 400 for bad input, 409 for a
// retryable state conflict (turn in flight / hive-c0re busy), 500 only
// for a genuine server/transport failure — the frontend shows the code
// in its alert, so a benign "busy, retry" must not read as a 500.
(status, message.to_owned()).into_response()
}
/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by