Every error response in the { error, code, details? } format (step 2)

Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.

- 500s: the body was the error's text (anyhow / diesel / {:?}); now
  ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
  statuses are unchanged. Shared helpers per module (faces_unavailable,
  person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
  duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.

Closes #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-01 13:29:13 -05:00
1 parent 1836f1b9ca
commit 431c3a7492
23 files changed
+894 -542

No files matched your search

+77 -89
View File
@@ -1,3 +1,4 @@
use crate::error::ApiError;
use actix_web::{HttpResponse, Responder, delete, get, post, web};
use futures::StreamExt;
use opentelemetry::trace::Status;
@@ -110,15 +111,12 @@ pub async fn generation_status_handler(
});
}
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Job {} not found", jid)
}));
return ApiError::not_found("job_not_found", "That insight job no longer exists")
.into_response();
}
Err(e) => {
log::error!("Failed to look up job {}: {:?}", jid, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up job"
}));
return ApiError::internal("Failed to look up job").into_response();
}
}
}
@@ -153,16 +151,12 @@ pub async fn generation_status_handler(
}
Err(e) => {
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to look up active job"
}));
return ApiError::internal("Failed to look up active job").into_response();
}
}
}
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Provide either job_id or path query parameter"
}))
ApiError::bad_request("invalid_request", "Give a job_id or a path").into_response()
}
#[derive(Debug, Deserialize)]
@@ -212,9 +206,7 @@ pub async fn cancel_generation_handler(
}
Err(e) => {
log::error!("Failed to cancel job {}: {:?}", jid, e);
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to cancel job"
}));
return ApiError::internal("Failed to cancel job").into_response();
}
}
}
@@ -267,9 +259,7 @@ pub async fn cancel_generation_handler(
}));
}
HttpResponse::BadRequest().json(serde_json::json!({
"error": "Provide either job_id or file_path in the request body"
}))
ApiError::bad_request("invalid_request", "Give a job_id or a file_path").into_response()
}
#[derive(Debug, Deserialize)]
@@ -491,9 +481,7 @@ pub async fn generate_insight_handler(
Err(e) => {
log::error!("Failed to create generation job: {:?}", e);
span.set_status(Status::error("Failed to create generation job"));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
return ApiError::internal("Failed to create generation job").into_response();
}
}
};
@@ -640,14 +628,10 @@ pub async fn get_insight_handler(
match dao.get_insight_for_paths(&sibling_paths) {
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": "Insight not found"
})),
Ok(None) => insight_not_found().into_response(),
Err(e) => {
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insight: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insight: {:?}", e)).into_response()
}
}
}
@@ -671,9 +655,7 @@ pub async fn delete_insight_handler(
})),
Err(e) => {
log::error!("Failed to delete insight: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to delete insight: {:?}", e)
}))
ApiError::internal(format!("Failed to delete insight: {:?}", e)).into_response()
}
}
}
@@ -699,9 +681,7 @@ pub async fn get_all_insights_handler(
}
Err(e) => {
log::error!("Failed to fetch all insights: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insights: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insights: {:?}", e)).into_response()
}
}
}
@@ -731,9 +711,7 @@ pub async fn get_insight_history_handler(
}
Err(e) => {
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to fetch insight history: {:?}", e)
}))
ApiError::internal(format!("Failed to fetch insight history: {:?}", e)).into_response()
}
}
}
@@ -822,9 +800,7 @@ pub async fn generate_agentic_insight_handler(
Err(e) => {
log::error!("Failed to create agentic generation job: {:?}", e);
span.set_status(Status::error("Failed to create generation job"));
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create generation job"
}));
return ApiError::internal("Failed to create generation job").into_response();
}
}
};
@@ -1159,9 +1135,7 @@ pub async fn rate_insight_handler(
})),
Err(e) => {
log::error!("Failed to rate insight: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to rate insight: {:?}", e)
}))
ApiError::internal(format!("Failed to rate insight: {:?}", e)).into_response()
}
}
}
@@ -1213,9 +1187,7 @@ pub async fn export_training_data_handler(
}
Err(e) => {
log::error!("Failed to export training data: {:?}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to export training data: {:?}", e)
}))
ApiError::internal(format!("Failed to export training data: {:?}", e)).into_response()
}
}
}
@@ -1308,9 +1280,7 @@ pub async fn chat_turn_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1361,17 +1331,17 @@ pub async fn chat_turn_handler(
// Map well-known errors to client-facing 4xx codes.
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("user_message")
|| msg.contains("unknown backend")
|| msg.contains("switching from local to hybrid")
|| msg.contains("hybrid backend unavailable")
{
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request("invalid_chat_request", msg).into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1456,9 +1426,7 @@ pub async fn chat_rewind_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1476,13 +1444,18 @@ pub async fn chat_rewind_handler(
let msg = format!("{}", e);
log::error!("Chat rewind failed: {}", msg);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1553,11 +1526,11 @@ pub async fn chat_history_handler(
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1606,14 +1579,18 @@ pub async fn chat_branches_handler(
Err(e) => {
let msg = format!("{}", e);
if msg.contains("no insight found") {
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
return insight_not_found().into_response();
} else if msg.contains("no chat history") {
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
return no_chat_history().into_response();
} else if msg.contains("not found in tree") {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
return ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response();
} else {
return HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": msg }));
return ApiError::internal(msg).into_response();
}
}
};
@@ -1638,9 +1615,7 @@ pub async fn chat_switch_branch_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1654,13 +1629,18 @@ pub async fn chat_switch_branch_handler(
let msg = format!("{}", e);
log::error!("Switch branch failed: {}", msg);
if msg.contains("no insight found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
insight_not_found().into_response()
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
no_chat_history().into_response()
} else if msg.contains("not found") || msg.contains("not a leaf") {
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
.into_response()
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg).into_response()
}
}
}
@@ -1698,9 +1678,7 @@ pub async fn chat_stream_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1853,9 +1831,7 @@ pub async fn turn_async_handler(
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {}", e)
}));
return libraries::unknown_library(e).into_response();
}
};
@@ -1936,9 +1912,7 @@ pub(crate) async fn turn_replay_impl(
Some(e) => e,
None => {
span.set_status(Status::error("turn not found"));
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("turn {} not found", turn_id)
}));
return turn_not_found().into_response();
}
};
@@ -1953,9 +1927,11 @@ pub(crate) async fn turn_replay_impl(
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
ReplayOutcome::Gone => {
span.set_status(Status::error("buffer evicted"));
return HttpResponse::Gone().json(serde_json::json!({
"error": "turn history has expired (buffer evicted)"
}));
return ApiError::gone(
"turn_expired",
"This reply is too old to resume. Reload the chat.",
)
.into_response();
}
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
ReplayOutcome::Events { events, next_skip } => {
@@ -2100,9 +2076,7 @@ pub(crate) async fn cancel_turn_impl(
Some(e) => e,
None => {
span.set_status(Status::error("turn not found"));
return HttpResponse::NotFound().json(serde_json::json!({
"error": format!("turn {} not found", turn_id)
}));
return turn_not_found().into_response();
}
};
@@ -2441,3 +2415,17 @@ mod owner_defaults_tests {
assert_eq!(persona.as_deref(), Some("journal"));
}
}
// ---- error responses (#202) ---------------------------------------------
fn insight_not_found() -> ApiError {
ApiError::not_found("insight_not_found", "There's no insight for this photo yet")
}
fn no_chat_history() -> ApiError {
ApiError::conflict("no_chat_history", "There's no chat for this insight yet")
}
fn turn_not_found() -> ApiError {
ApiError::not_found("turn_not_found", "That reply is no longer running")
}
+60 -50
View File
@@ -22,6 +22,7 @@
//! registry.
//! - The HTTP handlers under ` /persona_chat/*`.
use crate::error::ApiError;
use actix_web::{HttpResponse, Responder, delete, get, post, web};
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
@@ -85,6 +86,39 @@ impl std::fmt::Display for PersonaChatError {
impl std::error::Error for PersonaChatError {}
impl From<&PersonaChatError> for ApiError {
fn from(e: &PersonaChatError) -> Self {
match e {
PersonaChatError::UnknownPersona(_) => {
ApiError::not_found("persona_not_found", "That persona no longer exists")
}
PersonaChatError::UnknownConversation(_) => ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
),
PersonaChatError::EmptyMessage => {
ApiError::bad_request("empty_message", "Type a message first")
}
PersonaChatError::MessageTooLong => ApiError::bad_request(
"message_too_long",
"That message is too long (8192 characters at most)",
),
PersonaChatError::ConcurrentTurn => ApiError::conflict(
"turn_in_progress",
"A reply is still being written. Wait for it to finish.",
),
PersonaChatError::SmsThread(detail) => {
log::warn!("persona chat: SMS thread unreadable: {detail}");
ApiError::bad_gateway(
"sms_thread_unavailable",
"Couldn't read that message thread",
)
}
PersonaChatError::Db(err) => ApiError::internal(err),
}
}
}
impl From<anyhow::Error> for PersonaChatError {
fn from(e: anyhow::Error) -> Self {
PersonaChatError::Db(e)
@@ -1463,12 +1497,14 @@ pub async fn persona_chat_history_handler(
) {
Ok(view) => HttpResponse::Ok().json(view),
Err(e) => {
let msg = format!("{e}");
if msg.contains("not found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
if format!("{e}").contains("not found") {
ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
)
.into_response()
} else {
log::error!("persona chat history load failed: {msg}");
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(e).into_response()
}
}
}
@@ -1494,11 +1530,7 @@ pub async fn persona_chat_turn_handler(
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {e}")
}));
}
Err(e) => return crate::libraries::unknown_library(e).into_response(),
};
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
@@ -1519,21 +1551,7 @@ pub async fn persona_chat_turn_handler(
}
Err(e) => {
span.set_status(Status::error(format!("{e}")));
match &e {
PersonaChatError::UnknownPersona(_) | PersonaChatError::UnknownConversation(_) => {
HttpResponse::NotFound()
}
PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => {
HttpResponse::BadRequest()
}
PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(),
PersonaChatError::SmsThread(_) => HttpResponse::BadGateway(),
PersonaChatError::Db(_) => {
log::error!("persona chat dispatch failed: {e}");
HttpResponse::InternalServerError()
}
}
.json(serde_json::json!({ "error": format!("{e}") }))
ApiError::from(&e).into_response()
}
}
}
@@ -1567,20 +1585,7 @@ pub async fn persona_chat_create_conversation_handler(
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
"conversation_id": conversation_id,
})),
Err(e) => match &e {
PersonaChatError::UnknownPersona(_) => {
HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") }))
}
PersonaChatError::SmsThread(_) => {
log::warn!("anchored persona chat create failed: {e}");
HttpResponse::BadGateway().json(serde_json::json!({ "error": format!("{e}") }))
}
_ => {
log::error!("persona chat create failed: {e}");
HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": format!("{e}") }))
}
},
Err(e) => ApiError::from(&e).into_response(),
}
}
@@ -1600,9 +1605,7 @@ pub async fn persona_chat_delete_conversation_handler(
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
Err(e) => {
log::error!("persona chat delete failed: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("{e}")
}))
ApiError::internal(format!("{e}")).into_response()
}
}
}
@@ -1626,9 +1629,7 @@ pub async fn persona_chat_conversations_handler(
})),
Err(e) => {
log::error!("persona chat conversation list failed: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("{e}")
}))
ApiError::internal(format!("{e}")).into_response()
}
}
}
@@ -1669,20 +1670,29 @@ pub struct PersonaChatBranchesQuery {
/// equivalents return, so the client can share its error handling.
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
let msg = format!("{e}");
log::error!("{context}: {msg}");
log::warn!("{context}: {msg}");
if msg.contains("conversation not found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
)
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
ApiError::conflict("no_chat_history", "There's no chat here yet")
} else if msg.contains("not found")
|| msg.contains("not a leaf")
|| msg.contains("out of range")
|| msg.contains("cannot rewind past")
{
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
// The client's view of the branches is stale.
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg)
}
.into_response()
}
/// POST /persona_chat/rewind — move the tail of the conversation off the
+64 -46
View File
@@ -5,6 +5,7 @@
// voice from either an uploaded clip (device) or an existing library file
// (audio read directly; video has its audio track extracted via ffmpeg).
use crate::error::ApiError;
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, delete, get, post, web};
use anyhow::Context;
@@ -386,12 +387,11 @@ pub async fn tts_speech_handler(
let text = prepare_for_tts(&req.text);
if text.is_empty() {
span.set_status(Status::error("text is required"));
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
}
let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
return tts_unavailable().into_response();
};
let format = req
@@ -419,9 +419,11 @@ pub async fn tts_speech_handler(
// One synthesis at a time (see TTS_PERMIT) — fast-fail when busy.
let Ok(_permit) = TTS_PERMIT.try_acquire() else {
span.set_status(Status::error("tts busy"));
return HttpResponse::TooManyRequests().json(json!({
"error": "TTS is busy with another request — try again shortly"
}));
return ApiError::too_many_requests(
"tts_busy",
"Already reading something. Try again shortly.",
)
.into_response();
};
// Wait for the LLM side to release the GPU before sending — the synthesis
@@ -444,7 +446,7 @@ pub async fn tts_speech_handler(
Err(e) => {
span.set_status(Status::error("tts synthesis failed"));
log::error!("TTS synth failed: {:?}", e);
HttpResponse::BadGateway().json(json!({ "error": format!("TTS failed: {e}") }))
tts_failed(&e).into_response()
}
}
}
@@ -516,12 +518,11 @@ pub async fn create_speech_job_handler(
let text = prepare_for_tts(&req.text);
if text.is_empty() {
span.set_status(Status::error("text is required"));
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
}
if app_state.llamacpp.is_none() {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
return tts_unavailable().into_response();
}
let format = req
@@ -660,7 +661,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
span.set_status(Status::error("invalid job id"));
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
};
let resp = {
let jobs = TTS_JOBS.lock().unwrap();
@@ -679,8 +680,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
}
None => {
span.set_status(Status::error("job not found"));
HttpResponse::NotFound()
.json(json!({ "error": "TTS job not found (it may have expired)" }))
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
}
}
}
@@ -696,7 +696,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
span.set_status(Status::error("invalid job id"));
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
};
let status = with_job(id, |job| {
if !job.status.is_terminal() {
@@ -715,8 +715,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
}
None => {
span.set_status(Status::error("job not found"));
HttpResponse::NotFound()
.json(json!({ "error": "TTS job not found (it may have expired)" }))
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
}
}
}
@@ -753,8 +752,7 @@ pub async fn list_voices_handler(
let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured" }));
return tts_unavailable().into_response();
};
match client.list_voices().await {
Ok(v) => {
@@ -766,7 +764,7 @@ pub async fn list_voices_handler(
Err(e) => {
span.set_status(Status::error("list_voices failed"));
log::error!("list_voices failed: {:?}", e);
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
tts_failed(&e).into_response()
}
}
}
@@ -783,8 +781,7 @@ pub async fn delete_voice_handler(
let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured" }));
return tts_unavailable().into_response();
};
// Same charset rule as creation — a name that sanitizes differently was
// never a voice we created, and must not reach the upstream URL.
@@ -793,7 +790,7 @@ pub async fn delete_voice_handler(
Some(n) if n == raw => n,
_ => {
span.set_status(Status::error("invalid voice name"));
return HttpResponse::BadRequest().json(json!({ "error": "invalid voice name" }));
return invalid_voice_name().into_response();
}
};
span.set_attribute("tts.voice_name", name.clone());
@@ -807,7 +804,7 @@ pub async fn delete_voice_handler(
Err(e) => {
span.set_status(Status::error("delete_voice failed"));
log::error!("delete_voice failed: {:?}", e);
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
tts_failed(&e).into_response()
}
}
}
@@ -827,8 +824,7 @@ pub async fn create_voice_upload_handler(
let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured" }));
return tts_unavailable().into_response();
};
let mut voice_name: Option<String> = None;
@@ -853,8 +849,12 @@ pub async fn create_voice_upload_handler(
while let Some(Ok(data)) = part.next().await {
if file_bytes.len() + data.len() > MAX_VOICE_UPLOAD_BYTES {
span.set_status(Status::error("voice clip exceeds limit"));
return HttpResponse::PayloadTooLarge()
.json(json!({ "error": "voice clip exceeds 25 MB" }));
return ApiError::new(
actix_web::http::StatusCode::PAYLOAD_TOO_LARGE,
"body_too_large",
"The voice clip is too big (25 MB at most)",
)
.into_response();
}
file_bytes.put(data);
}
@@ -897,21 +897,20 @@ pub async fn create_voice_upload_handler(
Ok(w) => w,
Err(msg) => {
span.set_status(Status::error("invalid reference window"));
return HttpResponse::BadRequest().json(json!({ "error": msg }));
return ApiError::bad_request("invalid_clip_window", msg).into_response();
}
};
let Some(name) = voice_name.as_deref().and_then(sanitize_voice_name) else {
span.set_status(Status::error("voice_name is required"));
return HttpResponse::BadRequest()
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
return invalid_voice_name().into_response();
};
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
// library shows which reference length produced each clone.
let name = append_ref_window(&name, ref_start, ref_duration.round().max(1.0) as u32);
if file_bytes.is_empty() {
span.set_status(Status::error("voice_file is required"));
return HttpResponse::BadRequest().json(json!({ "error": "voice_file is required" }));
return ApiError::bad_request("no_file", "Choose an audio file").into_response();
}
span.set_attribute("tts.voice_name", name.clone());
span.set_attribute("tts.upload_bytes", file_bytes.len() as i64);
@@ -925,8 +924,7 @@ pub async fn create_voice_upload_handler(
Err(e) => {
span.set_status(Status::error("audio decode failed"));
log::error!("voice upload transcode failed: {:?}", e);
return HttpResponse::BadRequest()
.json(json!({ "error": "couldn't decode that audio file" }));
return undecodable_audio().into_response();
}
};
@@ -942,7 +940,7 @@ pub async fn create_voice_upload_handler(
Err(e) => {
span.set_status(Status::error("create_voice failed"));
log::error!("create_voice (upload) failed: {:?}", e);
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
tts_failed(&e).into_response()
}
}
}
@@ -978,20 +976,18 @@ pub async fn create_voice_from_library_handler(
let Some(client) = app_state.llamacpp.as_ref() else {
span.set_status(Status::error("tts backend not configured"));
return HttpResponse::ServiceUnavailable()
.json(json!({ "error": "TTS backend not configured" }));
return tts_unavailable().into_response();
};
let Some(voice_name) = sanitize_voice_name(&req.voice_name) else {
span.set_status(Status::error("voice_name is required"));
return HttpResponse::BadRequest()
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
return invalid_voice_name().into_response();
};
let (ref_start, ref_duration) =
match resolve_ref_window(req.start_seconds, req.duration_seconds) {
Ok(w) => w,
Err(msg) => {
span.set_status(Status::error("invalid reference window"));
return HttpResponse::BadRequest().json(json!({ "error": msg }));
return ApiError::bad_request("invalid_clip_window", msg).into_response();
}
};
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
@@ -1004,7 +1000,7 @@ pub async fn create_voice_from_library_handler(
Ok(None) => app_state.primary_library(),
Err(msg) => {
span.set_status(Status::error("invalid library"));
return HttpResponse::BadRequest().json(json!({ "error": msg }));
return libraries::unknown_library(msg).into_response();
}
};
@@ -1013,7 +1009,8 @@ pub async fn create_voice_from_library_handler(
Some(p) if p.exists() => p,
_ => {
span.set_status(Status::error("file not found"));
return HttpResponse::NotFound().json(json!({ "error": "file not found in library" }));
return ApiError::not_found("file_not_found", "That file doesn't exist")
.into_response();
}
};
@@ -1021,8 +1018,7 @@ pub async fn create_voice_from_library_handler(
// slurp arbitrary library files into memory / ffmpeg.
if !is_audio_file(&abs) && !is_video_file(&abs) {
span.set_status(Status::error("not an audio/video file"));
return HttpResponse::BadRequest()
.json(json!({ "error": "file is not an audio or video file" }));
return ApiError::bad_request("not_audio", "That file has no audio").into_response();
}
span.set_attribute("tts.voice_name", voice_name.clone());
@@ -1031,8 +1027,7 @@ pub async fn create_voice_from_library_handler(
Err(e) => {
span.set_status(Status::error("audio decode failed"));
log::error!("voice reference prep failed for {:?}: {:?}", abs, e);
return HttpResponse::BadRequest()
.json(json!({ "error": "couldn't decode that file's audio" }));
return undecodable_audio().into_response();
}
};
@@ -1048,7 +1043,7 @@ pub async fn create_voice_from_library_handler(
Err(e) => {
span.set_status(Status::error("create_voice failed"));
log::error!("create_voice (from-library) failed: {:?}", e);
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
tts_failed(&e).into_response()
}
}
}
@@ -1060,6 +1055,29 @@ async fn prepare_reference_audio(abs: &Path, start: f64, duration: f64) -> anyho
run_ffmpeg_to_wav(&abs.to_string_lossy(), start, duration).await
}
// ---- error responses (#202) ---------------------------------------------
fn tts_unavailable() -> ApiError {
ApiError::unavailable("tts_unavailable", "Read-aloud isn't set up on this server")
}
/// The voice backend failed; its message is kept in `details` for debugging.
fn tts_failed(e: &impl std::fmt::Display) -> ApiError {
ApiError::bad_gateway("tts_failed", "The voice service failed. Try again.")
.with_details(json!({ "reason": e.to_string() }))
}
fn invalid_voice_name() -> ApiError {
ApiError::bad_request(
"invalid_voice_name",
"Voice names can only use letters, numbers, - and _",
)
}
fn undecodable_audio() -> ApiError {
ApiError::bad_request("undecodable_audio", "Couldn't read the audio in that file")
}
#[cfg(test)]
mod tests {
use super::*;