fix(exception): return 405 for HttpRequestMethodNotSupportedException

Spring's default lets HttpRequestMethodNotSupportedException escape to the
catch-all @ExceptionHandler(Exception.class), surfacing as a 500 with a full
stack trace. Return a clean 405 so the client gets a structured R body and
the log stays at WARN.

This is also the second line of defence against malformed path segments that
confuse reverse proxies — e.g. a conversationId ending in ":" can make a
proxy strip the trailing path, landing a GET /api/v1/conversations/<id> on
the @DeleteMapping variant and triggering exactly this exception (issue #369).
This commit is contained in:
倪程伟 2026-06-18 11:30:13 +08:00 committed by matevip
parent 5893d4b33d
commit ffef9bab00
2 changed files with 83 additions and 0 deletions

View File

@ -9,6 +9,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@ -121,6 +122,21 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found"));
}
/**
* Spring's default lets this escape to the catch-all below, surfacing as a
* 500 with a stack trace. Return a clean 405 so the client gets a
* structured body and the log stays at WARN. Doubles as a defence when a
* malformed path segment makes a reverse proxy strip the trailing path
* (e.g. a conversationId ending in ":" lands a GET on a @DeleteMapping).
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<R<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e,
HttpServletRequest request) {
log.warn("Method not supported: {} {} (supported: {})",
request.getMethod(), request.getRequestURI(), e.getSupportedHttpMethods());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(R.fail(405, "Method not allowed"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<R<Void>> handleException(Exception e,
HttpServletRequest request,

View File

@ -0,0 +1,67 @@
package vip.mate.exception;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.i18n.I18nService;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Verifies that a path matched but the HTTP method did not surfaces as a clean
* HTTP 405 (handled by {@link GlobalExceptionHandler}) instead of leaking a 500
* with a full stack trace from the catch-all handler.
*
* <p>This is the second line of defence for malformed path segments that make
* a reverse proxy strip the trailing path e.g. a conversationId ending in
* ":" landing a GET on a @DeleteMapping route (upstream issue #369).
*/
class GlobalExceptionHandlerMethodNotSupportedTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
I18nService i18n = mock(I18nService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new ProbeController())
.setControllerAdvice(new GlobalExceptionHandler(i18n))
.build();
}
@Test
@DisplayName("GET on a @DeleteMapping-only route returns 405, not 500.")
void getOnDeleteOnlyRouteReturns405() throws Exception {
mockMvc.perform(get("/probe/abc"))
.andExpect(status().isMethodNotAllowed())
.andExpect(jsonPath("$.code").value(405))
.andExpect(jsonPath("$.msg").value("Method not allowed"));
}
@Test
@DisplayName("DELETE on the same route still resolves the handler normally.")
void deleteOnDeleteRouteReturns200() throws Exception {
mockMvc.perform(delete("/probe/abc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value("abc"));
}
/** Minimal stand-in for a controller whose path is mapped to DELETE only. */
@RestController
static class ProbeController {
@DeleteMapping("/probe/{id}")
R<String> probe(@PathVariable String id) {
return R.ok(id);
}
}
}