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::LooseEnds { loose_ends })) => loose_ends,
Ok(Ok(hive_sh4re::Response::Err { message })) => { 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() 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 { async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response {
let body = form.body.trim().to_owned(); let body = form.body.trim().to_owned();
if body.is_empty() { 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( let result = match tokio::time::timeout(
SOCKET_FETCH_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 // inbox row gets consumed by the time `TurnEnd` fires the
// existing turn-end refresh. // existing turn-end refresh.
Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), 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"); state.bus.emit_status("needs_login_in_progress");
(axum::http::StatusCode::OK, "ok").into_response() (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 { async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
let session = state.session.lock().unwrap().clone(); let session = state.session.lock().unwrap().clone();
let Some(session) = session else { 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 { 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() (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 { async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelForm>) -> Response {
let name = form.model.trim(); let name = form.model.trim();
if name.is_empty() { 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.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note { 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 { async fn post_set_effort(State(state): State<AppState>, Form(form): Form<EffortForm>) -> Response {
let level = form.effort.trim(); let level = form.effort.trim();
if !crate::events::is_valid_effort(level) { if !crate::events::is_valid_effort(level) {
return error_response(&format!( return error_response(
"effort: level must be one of {}", StatusCode::BAD_REQUEST,
crate::events::EFFORT_LEVELS.join(", ") &format!(
)); "effort: level must be one of {}",
crate::events::EFFORT_LEVELS.join(", ")
),
);
} }
state.bus.set_effort(level); state.bus.set_effort(level);
state.bus.emit(crate::events::LiveEvent::Note { 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 // Reject immediately if a normal turn is in flight — concurrent access
// to the claude session is unsafe and produces garbled output. // to the claude session is unsafe and produces garbled output.
let Ok(guard) = lock.try_lock_owned() else { 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 bus = state.bus.clone();
let files = state.files.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() (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 // Plain text — JS app surfaces in `alert()`, HTML wrapping would just
// be noise. // be noise. Status is per-caller: 400 for bad input, 409 for a
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() // 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 /// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by