refactor(api): migrate GlobalExceptionHandler to RFC 9457 ProblemDetail #466
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.javareturns a custom recordErrorResponse(ErrorCode code, String message)for every error path. RFC 9457 (application/problem+json) is the modern web-standard error format and is supported first-class in Spring Boot 4 viaorg.springframework.http.ProblemDetail.This is not a security or correctness issue — current handler is consistent and prevents stack-trace leaks (audit verified). It's an interop and ergonomics improvement: third-party API consumers, AI tooling, and standardized error-handling middleware all expect RFC 9457.
The
ErrorCodeenum has 47 codes — they all become thetypeURI suffix, preserving the typed error info already used by the frontend infrontend/src/lib/shared/errors.ts.Approach
Migrate every
@ExceptionHandlerto returnProblemDetail. KeepErrorCodeas the source of truth; expose it via acodeproperty in the ProblemDetail's extensions.Pattern
The frontend already extracts
codefrom the response body (perCLAUDE.mdAPI client convention). After migration, change the extractor to readresult.error.codeinstead of treating the whole body as{code, message}. Both work because the new shape is{type, title, status, detail, code}—codeis still present.Validation handlers
MethodArgumentNotValidExceptionandConstraintViolationExceptionshould add aerrorsproperty (array of field-error objects) per RFC 9457's "violations" extension convention:ResponseStatusExceptionhandlerSpring Boot 4 already handles this — consider removing the explicit handler and letting the framework's default produce a ProblemDetail.
Generic Exception handler
Keep the catch-all but switch to ProblemDetail. Critically, do not include the exception class name or stack trace in the response — the audit verified this is currently safe; preserve that property.
Critical files
backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.javabackend/src/main/java/org/raddatz/familienarchiv/exception/ErrorResponse.java— delete (dead after migration)backend/src/test/java/.../exception/GlobalExceptionHandlerTest.javafrontend/src/lib/shared/api.server.tsor wherever the error extractor lives — read from new shapefrontend/src/lib/shared/errors.ts— confirm thegetErrorMessageresolver still works (signature: input is{ code?: string })Verification
{type, title, status, detail, code}withContent-Type: application/problem+json.violations: [{field, message}, ...].code.npm run generate:apiproduces the new response shape; existing fetch wrappers still compile.Acceptance criteria
Content-Type: application/problem+json.codeproperty matching anErrorCodeenum value.violationsarray.getErrorMessagestill produces translated messages fromcode.Effort
S — half a day; the change is mechanical because every handler follows the same pattern.
Risk if not addressed
None operationally. Pure interop improvement.
Tracked in audit doc as F-16 (Medium).
🏛️ Markus Keller — Application Architect
Observations
GlobalExceptionHandleris a clean 70-line file; the new one will be roughly the same size.ProblemDetailis a first-class citizen in Spring Boot 4 / Spring Framework 7. No new dependency needed —spring-webalready ships it. TheContent-Type: application/problem+jsonis set automatically when returningProblemDetailfrom a@ExceptionHandler.ErrorResponserecord lives as an inner record ofGlobalExceptionHandler(line 71 of the file). It is not imported anywhere else in the backend — safe to delete after migration.THUMBNAIL_BACKFILL_ALREADY_RUNNINGandNOTIFICATION_NOT_FOUNDthat are absent fromfrontend/src/lib/shared/errors.ts. Frontend hasMISSING_CREDENTIALSthat is absent from the backend enum. This pre-existing gap should be fixed in the same PR — it is exactly the kind of hygiene this refactor motivates.ResponseStatusExceptionhandler currently usesex.getReason()as the detail message.getReason()is nullable in Spring 6+; a null reason passed toProblemDetail.forStatusAndDetail()will producenullin the detail field. Add a null guard.ResponseStatusExceptionhandler as potentially removable. In Spring Boot 4, the framework'sResponseEntityExceptionHandleralready handlesResponseStatusExceptionand produces aProblemDetail— but only if the handler class extends it. SinceGlobalExceptionHandlerdoes not extendResponseEntityExceptionHandler, the explicit handler must remain unless we change the class hierarchy. Recommendation: do not extendResponseEntityExceptionHandler— doing so implicitly inherits 20+ exception mappings that may conflict with the existing custom handlers. Keep the explicit handler, just switch it toProblemDetail.CLAUDE.mderror-handling section needs a note about the new response shape; no diagram changes required.Recommendations
@ControllerAdvice+ResponseBodyAdviceor keep@RestControllerAdviceas-is — no class hierarchy change needed.typeURI to an enum-keyed path:URI.create("https://errors.familienarchiv.app/" + code.name()). Even if the domain never resolves, RFC 9457 allows it to be a non-dereferenceable identifier.ex.getReason()in theResponseStatusExceptionhandler:pd.setDetail(ex.getReason() != null ? ex.getReason() : ex.getMessage()).ErrorCodedrift in the same PR: addTHUMBNAIL_BACKFILL_ALREADY_RUNNINGandNOTIFICATION_NOT_FOUNDtoerrors.ts, removeMISSING_CREDENTIALSfrom the frontend type union (or add it to the backend enum if it is genuinely used).docs/ARCHITECTURE.mdunder a new "Error response format" section — capturing why RFC 9457 was chosen and what thecodeextension property preserves.Open Decisions
violationsarray format for validation errors: The issue proposes[{field, message}]. ForConstraintViolationException, the property path includes the method name prefix (e.g.,createPerson.dto.lastName). Decide whether to strip the prefix before exposing it in theviolationsarray or expose the raw path. Raw is simpler; stripped is more consumer-friendly. Pick one and specify it in the issue before implementation starts.👨💻 Felix Brandt — Fullstack Developer
Observations
(result.error as unknown as { code?: string })?.code(the typed client path); a smaller set useparseBackendError(res)with rawfetch(the file upload and invite paths). Both readbody.code— that field is preserved in the new{type, title, status, detail, code}shape, so both patterns continue to work after migration without any frontend changes. This is the correct call in the issue.handleResponseStatushandler on line 59 passesErrorCode.VALIDATION_ERRORas the code for allResponseStatusExceptioninstances. This is wrong for non-validation uses — e.g., aResponseStatusException(HttpStatus.FORBIDDEN, ...)would emitVALIDATION_ERRORas the code. After migration toProblemDetailthis should be corrected: use a sentinelErrorCode.INTERNAL_ERRORor derive it from the status code.MethodArgumentNotValidExceptionandConstraintViolationExceptionhandlers concatenate field errors into a single comma-joined string. The issue proposes moving those to a structuredviolationsarray, which is the right call. The concatenated string is currently themessagefield thatgetErrorMessage()receives — butgetErrorMessage()ignores the message and maps only oncode, so consumers do not rely on this string. Safe to restructure.ErrorResponseinner record is self-contained on line 71 — no external import. Delete it and the compiler confirms all references are gone.handleGenericalready guards against leak ("An unexpected error occurred") — preserve exactly that behaviour. Do not change the detail string.GlobalExceptionHandlerTest. The issue lists one as a critical file to create. This is the first test coverage for the handler — good motivation for TDD here.Recommendations
@WebMvcTestslice loading a minimal controller that throws each exception type, assertingContent-Type: application/problem+json, status code, and the presence ofcodein the body. One test per exception type.ResponseStatusExceptionhandler's hardcodedErrorCode.VALIDATION_ERROR: replace with a status-to-code mapping or useINTERNAL_ERRORas fallback. A simple helpererrorCodeForStatus(HttpStatus)returningFORBIDDENfor 403,UNAUTHORIZEDfor 401, etc., is worth the 10 lines.handleGenericmessage as-is. The detail field in ProblemDetail maps 1:1 to what was previouslymessage— no information added or removed.npm run generate:api. The OpenAPI spec currently reflectsErrorResponse {code, message}as an error response shape. After migration the spec will reflectProblemDetail. Confirm the frontend typed client still compiles — theresult.error as unknowncast is already untyped so it will not break, but the generated types should be clean.violationsmapping into a private helper method to keep each@ExceptionHandlerbody under 10 lines:private List<Map<String,String>> toViolations(BindingResult result).Open Decisions
(none — the implementation path is clear)
🔧 Tobias Wendt — DevOps & Platform Engineer
Observations
Content-Typeheader change fromapplication/jsontoapplication/problem+jsonis the only observable difference at the HTTP layer. Caddy does not inspect or filter response bodies, so the reverse proxy config requires no update./v3/api-docs(dev profile only) will reflect the new error shape aftergenerate:api. If any CI job caches a snapshot of the spec, clear it. If not — no action needed.Recommendations
application/problem+jsonis returned in the real environment.Open Decisions
(none)
📋 Elicit — Requirements Engineer
Observations
getErrorMessagestill produces translated messages fromcode" is phrased as an outcome but has no corresponding verification step in the "Verification" section. Section 3 (Frontend smoke) is the implicit test, but it is described as a manual check. For a solo developer this is fine, but it could be strengthened by specifying it as an automated test assertion.frontend/src/lib/shared/api.server.tsas a file to change ("change the extractor to readresult.error.code"). Inspection of the actual codebase shows thatapi.server.tsis only the client factory; the error extraction happens in individual+page.server.tsfiles using(result.error as unknown as { code?: string })?.code. The issue's claim that "both work" is correct — thecodefield is present in both shapes — so no extractor change is actually required. The issue slightly overstates the frontend scope; the implementation should not change those extraction sites unnecessarily.ErrorCodedrift (see Markus's comment) is a requirements gap that should be captured as an acceptance criterion if it is to be fixed in this PR, or as a separate issue if deferred.Recommendations
ErrorCodetype infrontend/src/lib/shared/errors.tsis in sync with the backendErrorCodeenum (no missing or extra values)." This closes the pre-existing drift and keeps the PR self-contained.api.server.tsfrom the issue body to avoid confusion — the typed client pattern does not need to change.Open Decisions
(none — the spec is ready to implement as-is with the minor clarification above)
🔒 Nora "NullX" Steiner — Security Engineer
Observations
"An unexpected error occurred"— this is the right baseline. The audit conclusion ("prevents stack-trace leaks") is confirmed by reading the code.typeURI: the issue proposeshttps://errors.familienarchiv.app/DOCUMENT_NOT_FOUNDas thetypeURI. This exposes theErrorCodeenum value in the response body via the URI, which is intentional and already exposed via thecodeproperty. No new information is leaked. However, if thetypeURI ever resolves (i.e., the domain becomes real), ensure the endpoint returns only documentation — not anything that reveals internal structure beyond the enum name.detailfield in ProblemDetail forDomainException: thedetailwill beex.getMessage(), which is the developer message fromDomainException's constructor. Audit a sample:"Document not found: " + id— this exposes a UUID to the API consumer. This was already the behaviour with the previousmessagefield, and is acceptable for this application (family archive, no sensitive UUIDs), but note it for awareness.violationsarray for validation errors: exposing field names is standard RFC 9457 practice and already effectively exposed by the current comma-joinedmessagestring. No regression here.ResponseStatusExceptionwith null reason:ex.getReason()can return null. If this null flows intoProblemDetail.setDetail(null), the serialised JSON will have"detail": null. Not a security issue, but a subtle functional regression — the previous handler serialisednullas the message field inErrorResponsewithout issue, but consumers expecting a string detail field will receive null. Null-guard as Markus recommends.handleGenericcatch-all is the most security-sensitive handler — it must never expose the original exception. The proposed migration preserves the safe static string. Confirmed safe.Recommendations
handleGeneric, explicitly do not setpd.setDetail(ex.getMessage())orpd.setTitle(ex.getClass().getSimpleName())— both would be information disclosure. Use only the static"An unexpected error occurred"string as the detail.@WebMvcTestassertion that hitting an endpoint that throws a rawRuntimeExceptionreturns a body that does not contain the exception message or class name. This is a permanent regression guard for the most sensitive handler.ErrorResponse.java(actually the inner record), confirm withgrep -r ErrorResponse backend/srcthat it has no external references. From my reading it is self-contained, but confirm mechanically.typeURI approach is sound from a security standpoint — it follows the RFC and avoids leaking internal system details beyond the enum name which is already public API.Open Decisions
(none — no genuine security tradeoffs here; all recommendations above are unambiguous)
🧪 Sara Holt — QA Engineer
Observations
GlobalExceptionHandlerTestin the codebase. This is the first time the handler will have direct test coverage. The issue correctly lists it as a critical file to create — this is the most important deliverable of the PR alongside the handler itself.@WebMvcTestwith a minimal test controller that throws each exception type. This avoids loading the full Spring context, MinIO, PostgreSQL, etc. Five exception types × two assertions each (status code + body shape) = ~10 test methods. This is a 10-second test class.Content-Type: application/problem+jsonassertion is the most important new test. It is the primary observable change and the thing most likely to regress if a future change reverts the handler. One assertion per handler method:andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)).MethodArgumentNotValidException,ConstraintViolationException) must be tested for the presence of theviolationsarray in the response body. UsejsonPath("$.violations").isArray()andjsonPath("$.violations[0].field").exists().GlobalExceptionHandlerTestwithout the handler test class would lower coverage (new lines without tests). The PR must include the test class or the coverage gate will fail.Recommendations
GlobalExceptionHandlerTestas a@WebMvcTestslice. Test method naming convention:should_return_problemDetail_with_code_when_domainException_is_thrown(),should_include_violations_array_when_validation_fails(),should_not_include_stack_trace_when_generic_exception_is_thrown().@Testper@ExceptionHandlermethod — do not combine them into one mega-test.jsonPath("$.detail").value("An unexpected error occurred")) and the presence ofcode(jsonPath("$.code").value("INTERNAL_ERROR")).Content-Typeheader in every test, not just a representative sample:andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)).Open Decisions
ConstraintViolationException: triggering this in a@WebMvcTestrequires a@Validatedcontroller with a bean validation constraint. Decide whether to use a real controller method with@Validon a simple DTO, or to mock the exception in a@MockBeanservice. The former is more realistic; the latter is simpler to set up. Both are valid — pick one and be consistent with how other controller tests in the project are structured.🎨 Leonie Voss — UI/UX & Accessibility
Observations
getErrorMessage(code)continues to work and i18n resolution is preserved. Thecodefield is present in both old and new shapes, so Paraglide-translated messages will continue to render correctly. Users will see no change.errors.tshasOCR_TRAINING_CONFLICTmapped tom.error_internal_error()(line 132 of the file) rather than a dedicated message. This is a pre-existing gap unrelated to this issue, but worth noting: if a user triggers an OCR training conflict, they see a generic "internal error" message. Not a blocker for this PR.violationsarray in validation error responses is backend-only at this stage. If in future a form wants to display per-field server-side validation errors (rather than the current generic "validation error" toast), the structuredviolationsarray makes that significantly easier. This is a design enabler, not a current requirement.Recommendations
violationsarray is eventually surfaced in form components, use thefieldvalue to match to a visible<label>and display the error adjacent to the relevant input — consistent with the existing inline validation pattern. Plan for this when a future issue adds server-side field-level error display.Open Decisions
(none — no UX tradeoffs in a backend wire format change)
Decision Queue
Two open decisions were raised across the review. Both need to be resolved before implementation starts.
D-1:
violationsfield path format forConstraintViolationException(Markus + Sara)Context:
ConstraintViolationException.getConstraintViolations()gives property paths that include the method name prefix — e.g.,createPerson.dto.lastNameinstead of justlastName. Theviolationsarray will expose these paths to consumers.Options:
createPerson.dto.lastNameas-is. No transformation code. Clients receive the full path.lastName. Requires a small string transform (path.toString().replaceAll("^[^.]+\\.", "")).Recommendation: B (stripped). The method name is an implementation detail with no value to API consumers. A 10-character regex is a cheap transformation.
Decision needed by: before implementation starts.
D-2: Test fixture strategy for
ConstraintViolationExceptionin@WebMvcTest(Sara)Context:
ConstraintViolationExceptionis thrown when a@Validatedcontroller parameter fails a bean validation constraint (e.g.,@NotBlank). Triggering it in a@WebMvcTestslice requires either (A) a real@ValidatedDTO on a test controller endpoint, or (B) mocking the exception from a@MockBeanservice.Options:
@ValidDTO on a stub endpoint: more realistic, exercises the Spring validation pipeline end-to-end, but slightly more setup.Recommendation: A (real
@ValidDTO). The intent of the test is to confirm the full path — Spring validation firing, exception propagating, handler formatting theviolationsarray. Mocking bypasses the validation step and gives less confidence.Decision needed by: before writing
GlobalExceptionHandlerTest.