Closes#202. Step 1 (#203) added ApiError and moved tags and Enhance onto it; this moves everything else.
What changed
~300 hand-built error responses across 21 modules now go through ApiError.
500s never send their cause. About 100 sites sent the error's text (anyhow, diesel, {:?}) as the body, which could include SQL and library paths. They now go through ApiError::internal, which logs the cause and sends {"error":"Something went wrong on the server","code":"internal"}.
Every 4xx, 502 and 503 has a code and a message written to show a user. Statuses are unchanged, so a client that branches on status keeps working.
One helper per repeated case, e.g. faces_unavailable, person_not_found, libraries::unknown_library, insight_not_found, file_not_found, tts_failed. PersonaChatError converts into ApiError in one place.
Older top-level fields kept: the upload 409 still has duplicate_of, content_hash and library_name at the top level (also under details). The tag rename 409 still has existing_tag (from #203).
Multipart upload errors use the format: invalid_upload, body_too_large (error::multipart_config).
Guard test (error::tests::no_handler_builds_an_error_response_by_hand): fails with file:line if non-test code builds HttpResponse::NotFound() (or any error status) by hand. Checked by planting one.
CLAUDE.md gains an "Error responses" section.
Not covered: actix-governor's 429 on /login (plain text; changing it means a custom key extractor). SSE error_message events are a separate channel and unchanged.
Full suite 1068 passing (+1 guard test), clippy clean.
Over HTTP on the e2e server: 15 error cases across login, image, photos, memories, persons, personas, insights, reels, versions, duplicates and search each answer with the expected status and code; favorites, photos, memories, persons, login and a thumbnail still answer 200.
Closes #202. Step 1 (#203) added `ApiError` and moved tags and Enhance onto it; this moves everything else.
## What changed
~300 hand-built error responses across 21 modules now go through `ApiError`.
- **500s never send their cause.** About 100 sites sent the error's text (anyhow, diesel, `{:?}`) as the body, which could include SQL and library paths. They now go through `ApiError::internal`, which logs the cause and sends `{"error":"Something went wrong on the server","code":"internal"}`.
- **Every 4xx, 502 and 503 has a code** and a message written to show a user. **Statuses are unchanged**, so a client that branches on status keeps working.
- **One helper per repeated case**, e.g. `faces_unavailable`, `person_not_found`, `libraries::unknown_library`, `insight_not_found`, `file_not_found`, `tts_failed`. `PersonaChatError` converts into `ApiError` in one place.
- **Older top-level fields kept**: the upload 409 still has `duplicate_of`, `content_hash` and `library_name` at the top level (also under `details`). The tag rename 409 still has `existing_tag` (from #203).
- **Multipart upload errors** use the format: `invalid_upload`, `body_too_large` (`error::multipart_config`).
- **Guard test** (`error::tests::no_handler_builds_an_error_response_by_hand`): fails with file:line if non-test code builds `HttpResponse::NotFound()` (or any error status) by hand. Checked by planting one.
- CLAUDE.md gains an "Error responses" section.
Not covered: actix-governor's 429 on `/login` (plain text; changing it means a custom key extractor). SSE `error_message` events are a separate channel and unchanged.
## Codes (104)
Auth: `unauthorized`, `invalid_credentials`, `weak_password`, `username_taken`, `invalid_registration`
Request: `invalid_body`, `invalid_query`, `invalid_path`, `invalid_upload`, `body_too_large`, `unknown_route`, `internal`
Files & photos: `file_not_found`, `folder_not_found`, `photo_not_indexed`, `thumbnail_unavailable`, `unknown_library`, `library_not_found`, `invalid_excluded_dirs`, `incomplete_gps_filter`, `invalid_date`, `invalid_move`, `gps_write_unsupported`, `exiftool_unavailable`, `no_file_name`, `empty_upload`, `duplicate_upload`
Video: `not_a_video`, `stream_not_found`, `stream_link_expired`, `too_many_files`
Tags: `invalid_tag_name`, `tag_not_found`, `tag_name_taken`, `tag_added_and_removed`, `no_files`, `nothing_to_change`
Versions & duplicates: `not_a_version`, `version_in_other_library`, `survivor_is_duplicate`
Faces & persons: `faces_unavailable`, `faces_busy`, `faces_bad_response`, `no_face_found`, `invalid_face_box`, `face_not_found`, `invalid_person_name`, `person_not_found`, `person_name_taken`, `merge_into_self`
Search: `empty_query`, `nothing_to_search`, `query_rejected`, `query_not_understood`, `search_unavailable`, `search_bad_response`, `llm_unavailable`
Insights & chat: `insight_not_found`, `no_chat_history`, `invalid_chat_request`, `stale_branch`, `turn_not_found`, `turn_expired`, `job_not_found`, `invalid_request`
Persona chat & personas: `persona_not_found`, `conversation_not_found`, `empty_message`, `message_too_long`, `turn_in_progress`, `sms_thread_unavailable`, `invalid_persona`, `persona_id_taken`, `builtin_persona`
Knowledge: `entity_not_found`, `fact_not_found`, `invalid_fact`, `supersede_self`
Reels: `reels_unavailable`, `invalid_reel_request`, `no_memories`, `invalid_job_id`, `reel_not_ready`, `reel_missing`, `no_precomputed_reel`
TTS: `tts_unavailable`, `tts_busy`, `tts_failed`, `empty_text`, `invalid_voice_name`, `invalid_clip_window`, `no_file`, `not_audio`, `undecodable_audio`
Prefs: `invalid_time_zone`, `invalid_backend`, `invalid_span`
Enhance (unchanged from before #202): `disabled`, `unavailable`, `unknown_template`, `bad_request`, `rejected`, `expired`, `not_found`, `unplaceable`, `upstream`
## Testing
- Full suite 1068 passing (+1 guard test), clippy clean.
- Over HTTP on the e2e server: 15 error cases across login, image, photos, memories, persons, personas, insights, reels, versions, duplicates and search each answer with the expected status and code; favorites, photos, memories, persons, login and a thumbnail still answer 200.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
cameron
merged commit ce101a3b2c into master2026-11-01 18:37:09 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #202. Step 1 (#203) added
ApiErrorand moved tags and Enhance onto it; this moves everything else.What changed
~300 hand-built error responses across 21 modules now go through
ApiError.{:?}) as the body, which could include SQL and library paths. They now go throughApiError::internal, which logs the cause and sends{"error":"Something went wrong on the server","code":"internal"}.faces_unavailable,person_not_found,libraries::unknown_library,insight_not_found,file_not_found,tts_failed.PersonaChatErrorconverts intoApiErrorin one place.duplicate_of,content_hashandlibrary_nameat the top level (also underdetails). The tag rename 409 still hasexisting_tag(from #203).invalid_upload,body_too_large(error::multipart_config).error::tests::no_handler_builds_an_error_response_by_hand): fails with file:line if non-test code buildsHttpResponse::NotFound()(or any error status) by hand. Checked by planting one.Not covered: actix-governor's 429 on
/login(plain text; changing it means a custom key extractor). SSEerror_messageevents are a separate channel and unchanged.Codes (104)
Auth:
unauthorized,invalid_credentials,weak_password,username_taken,invalid_registrationRequest:
invalid_body,invalid_query,invalid_path,invalid_upload,body_too_large,unknown_route,internalFiles & photos:
file_not_found,folder_not_found,photo_not_indexed,thumbnail_unavailable,unknown_library,library_not_found,invalid_excluded_dirs,incomplete_gps_filter,invalid_date,invalid_move,gps_write_unsupported,exiftool_unavailable,no_file_name,empty_upload,duplicate_uploadVideo:
not_a_video,stream_not_found,stream_link_expired,too_many_filesTags:
invalid_tag_name,tag_not_found,tag_name_taken,tag_added_and_removed,no_files,nothing_to_changeVersions & duplicates:
not_a_version,version_in_other_library,survivor_is_duplicateFaces & persons:
faces_unavailable,faces_busy,faces_bad_response,no_face_found,invalid_face_box,face_not_found,invalid_person_name,person_not_found,person_name_taken,merge_into_selfSearch:
empty_query,nothing_to_search,query_rejected,query_not_understood,search_unavailable,search_bad_response,llm_unavailableInsights & chat:
insight_not_found,no_chat_history,invalid_chat_request,stale_branch,turn_not_found,turn_expired,job_not_found,invalid_requestPersona chat & personas:
persona_not_found,conversation_not_found,empty_message,message_too_long,turn_in_progress,sms_thread_unavailable,invalid_persona,persona_id_taken,builtin_personaKnowledge:
entity_not_found,fact_not_found,invalid_fact,supersede_selfReels:
reels_unavailable,invalid_reel_request,no_memories,invalid_job_id,reel_not_ready,reel_missing,no_precomputed_reelTTS:
tts_unavailable,tts_busy,tts_failed,empty_text,invalid_voice_name,invalid_clip_window,no_file,not_audio,undecodable_audioPrefs:
invalid_time_zone,invalid_backend,invalid_spanEnhance (unchanged from before #202):
disabled,unavailable,unknown_template,bad_request,rejected,expired,not_found,unplaceable,upstreamTesting
🤖 Generated with Claude Code
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>