fix(restapi): NoResource/NoHandler → 404, не 500

GlobalExceptionHandler catch-all Exception → 500 ловил NoResourceFoundException
и NoHandlerFoundException. Симптомы на prod:

- /swagger-ui/index.html при disabled springdoc возвращал 500 (UI всё равно
  не рендерился, но spring логировал 'Unhandled exception' на каждый запрос —
  спамил alerting и затруднял поиск настоящих ошибок).
- GET /api/v1/typo от integrator'ов давал 500 вместо 404, осложняло
  debugging без traceId-correlation.
- Любой stale frontend bundle с переименованным endpoint мог триггерить
  false-positive incident alerts.

Fix: explicit @ExceptionHandler для обоих exception типов → 404 not_found.
Логирование DEBUG (не WARN/ERROR) — это ожидаемый 404, не аномалия.

Test: SmokeE2ETest.unknownPath_returns404WithNotFoundCode покрывает оба пути
(/api/v1/* и /swagger-ui/*) → 404 + body.code='not_found'.
This commit is contained in:
Zimin A.N.
2026-05-13 00:00:36 +03:00
parent 6c48cfac46
commit f2adc37e2e
2 changed files with 68 additions and 0 deletions
@@ -127,4 +127,33 @@ class SmokeE2ETest {
assertThat(newEntries).isNotEmpty();
assertThat(newEntries.get(0).getBusinessKey()).isEqualTo(businessKey);
}
/**
* Regression: unrouted path должен возвращать 404 not_found, не 500
* internal_error.
*
* <p>До MR-fix: NoResourceFoundException попадал в generic Exception handler
* в GlobalExceptionHandler → 500. Симптомы на prod включали:
* <ul>
* <li>{@code /swagger-ui/index.html} при disabled springdoc → 500 вместо 404
* (UI не рендерился но logs спамились "Unhandled exception").</li>
* <li>Integrator'ы при typo в endpoint path получали 500 — затрудняло
* debugging без traceId correlation.</li>
* </ul>
*
* <p>Покрывает оба пути: путь похожий на API ({@code /api/v1/notexist}) и
* generic non-API ({@code /random/path}).
*/
@Test
void unknownPath_returns404WithNotFoundCode() throws Exception {
mvc.perform(get("/api/v1/this-endpoint-does-not-exist"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.code").value("not_found"));
mvc.perform(get("/swagger-ui/index.html"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.code").value("not_found"));
}
}
@@ -11,6 +11,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.time.format.DateTimeParseException;
@@ -73,6 +75,43 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(body);
}
/**
* Path не имеет зарегистрированного controller'а ИЛИ static resource'а.
*
* <p>Spring MVC throws:
* <ul>
* <li>{@link NoResourceFoundException} — когда ResourceHttpRequestHandler не
* нашёл static resource. Поднимается на любой запрос вида
* {@code /api/v1/typo} к unrouted path, потому что dispatch fall'sback
* на static resource resolver.</li>
* <li>{@link NoHandlerFoundException} — когда
* {@code spring.mvc.throw-exception-if-no-handler-found=true} и нет
* static resource fallback. В нашем setup'е fallback включён, поэтому
* приоритетно срабатывает NoResourceFoundException, но handler покрывает
* обе ситуации для robustness.</li>
* </ul>
*
* <p>До этого handler'а оба exception'а попадали в generic {@link Exception}
* catch ниже → возвращали 500 internal_error. Эффекты:
* <ul>
* <li>Disabled springdoc.swagger-ui (prod) — {@code /swagger-ui/index.html}
* вместо чистого 404 давал 500 с записью "Unhandled exception" в logs,
* спамил alerting.</li>
* <li>{@code GET /api/v1/typo} — клиенты получали 500 вместо 404,
* затрудняло debugging integrator'ам.</li>
* <li>Любой stale frontend bundle с переименованным endpoint в новой версии
* мог триггерить false-positive incident alerts.</li>
* </ul>
*
* <p>Лог уровня DEBUG (не WARN/ERROR) — это ожидаемый 404, не аномалия.
*/
@ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class})
public ResponseEntity<ApiErrorResponse> onNotFound(Exception e) {
log.debug("Resource not found: {}", e.getMessage());
var body = ApiErrorResponse.of(404, "not_found", "Resource not found", currentTraceId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> onGeneric(Exception e) {
log.error("Unhandled exception in REST API", e);