package vip.mate.exception; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; 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; import org.springframework.web.servlet.resource.NoResourceFoundException; import vip.mate.common.result.R; import vip.mate.i18n.I18nService; import vip.mate.skill.lifecycle.ConfirmRequiredException; import java.util.LinkedHashMap; import java.util.Map; /** * Global exception handler. *

* MateClawException messages are translated through I18nService when the * exception provides a message key. The JSON body keeps the project-wide * R envelope while the HTTP status mirrors the failure class. * * @author MateClaw Team */ @Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { private final I18nService i18nService; @ExceptionHandler(AsyncRequestTimeoutException.class) public ResponseEntity> handleAsyncTimeout(AsyncRequestTimeoutException e, HttpServletRequest request, HttpServletResponse response) { if (isSseRequest(request) || response.isCommitted()) { log.debug("SSE async timeout (normal lifecycle): {} {}", request.getMethod(), request.getRequestURI()); // Return no body for SSE so the framework can end the async request. return null; } log.warn("Async request timeout: {} {}", request.getMethod(), request.getRequestURI()); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body(R.fail(503, "Request timeout, please try again")); } @ExceptionHandler(MateClawException.class) public ResponseEntity> handleMateClawException(MateClawException e) { log.warn("Business exception: [{}] {}", e.getCode(), e.getMessage()); String msg = translateExceptionMsg(e); return ResponseEntity.status(httpStatusForCode(e.getCode())).body(R.fail(e.getCode(), msg)); } /** * Translate exception message via i18n. * If the exception has a msgKey, use it to look up the translated message. * Otherwise return the original message as-is. */ private String translateExceptionMsg(MateClawException e) { String msgKey = e.getMsgKey(); if (msgKey != null && !msgKey.isEmpty()) { String translated = i18nService.msg(msgKey); if (!translated.equals(msgKey)) { return translated; } } return e.getMessage(); } /** * A mutation that needs a second, explicit confirmation. Returns a real * HTTP 409 with a structured body so the client can branch on the status * code and render a confirm dialog from {@code boundAgents}. */ @ExceptionHandler(ConfirmRequiredException.class) public ResponseEntity> handleConfirmRequired(ConfirmRequiredException e) { log.info("Confirm required: [{}] {}", e.getCode(), e.getMessage()); Map body = new LinkedHashMap<>(); body.put("code", e.getCode()); body.put("message", e.getMessage()); body.put("boundAgents", e.getBoundAgents()); return ResponseEntity.status(HttpStatus.CONFLICT).body(body); } @ExceptionHandler(BindException.class) public ResponseEntity> handleBindException(BindException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .findFirst() .orElse("Validation failed"); log.warn("Validation failed: {}", msg); return ResponseEntity.badRequest().body(R.fail(400, msg)); } /** * A path variable or request parameter could not be coerced into the * handler's declared type (e.g. a non-numeric segment on an {@code /{id}} * route bound to {@code Long}). This is a malformed client request, not a * server fault, so it must surface as 400 — never a 500 with a full stack * trace from the catch-all handler below. */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) { String required = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "expected type"; String msg = "Invalid value for parameter '" + e.getName() + "': expected " + required; log.warn("Argument type mismatch: {} {} - {}", request.getMethod(), request.getRequestURI(), msg); return ResponseEntity.badRequest().body(R.fail(400, msg)); } @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity> handleNoResourceFound(NoResourceFoundException e, HttpServletRequest request) { log.warn("Resource not found: {} {}", request.getMethod(), request.getRequestURI()); 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> 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> handleException(Exception e, HttpServletRequest request, HttpServletResponse response) { if (response.isCommitted() || isSseRequest(request)) { if (isExpectedClientDisconnect(e)) { // Browser reloads and tab closes routinely tear down the SSE // socket. This is transport lifecycle noise, not an // application warning, and should not page operators. log.debug("SSE client disconnected: {} {} - {}", request.getMethod(), request.getRequestURI(), e.getMessage()); } else { log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}", request.getMethod(), request.getRequestURI(), e.getMessage()); } return null; } log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(R.fail("Internal server error")); } /** * Identify SSE requests from the Accept header, Content-Type, or known stream path. */ private boolean isSseRequest(HttpServletRequest request) { String accept = request.getHeader("Accept"); if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { return true; } String contentType = request.getContentType(); if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { return true; } // Fallback path match for stream endpoints that omit explicit headers. String uri = request.getRequestURI(); return uri != null && uri.contains("/chat/stream"); } static boolean isExpectedClientDisconnect(Throwable error) { for (Throwable current = error; current != null; current = current.getCause()) { String type = current.getClass().getName(); String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase(); if (type.endsWith("ClientAbortException") || message.contains("broken pipe") || message.contains("connection reset") || message.contains("disconnected client") || message.contains("connection aborted")) { return true; } } return false; } private HttpStatus httpStatusForCode(int code) { HttpStatus status = HttpStatus.resolve(code); return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR; } }