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

Merged
cameron merged 1 commits from feat/error-contract-step2 into master 2026-11-01 18:37:09 +00:00
Owner

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

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)
cameron added 1 commit 2026-11-01 18:29:39 +00:00
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 master 2026-11-01 18:37:09 +00:00
cameron deleted branch feat/error-contract-step2 2026-11-01 18:37:10 +00:00
Sign in to join this conversation.