Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;

/**
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
Expand Down Expand Up @@ -111,6 +112,19 @@ public ResponseEntity<ApiResponse<Void>> handleStorageAccess(StorageAccessExcept
apiResponseFactory.error(503, "error.storage.unavailable"));
}

@ExceptionHandler(AsyncRequestTimeoutException.class)
public ResponseEntity<?> handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) {
String path = request.getRequestURI();
if (path != null && path.endsWith("/sse")) {
logger.debug("SSE timeout [requestId={}, path={}]", MDC.get("requestId"), path);
return ResponseEntity.noContent().build();
}

logHandledException(HttpStatus.REQUEST_TIMEOUT, "error.request.timeout", request);
return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body(
apiResponseFactory.error(408, "error.request.timeout"));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
Expand All @@ -122,7 +136,7 @@ public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, Htt
ex
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
apiResponseFactory.error(500, "error.internal"));
apiResponseFactory.error(500, "error.internal"));
}

private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
Expand Down
1 change: 1 addition & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap pr
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
error.badRequest=Invalid request
error.forbidden=Forbidden
error.request.timeout=Request timed out
error.rateLimit.exceeded=Rate limit exceeded
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.
error.internal=An unexpected error occurred
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
error.badRequest=请求参数不合法
error.forbidden=没有权限执行该操作
error.request.timeout=请求超时
error.rateLimit.exceeded=请求过于频繁,请稍后再试
error.storage.unavailable=对象存储暂时不可用,请稍后再试
error.internal=服务器内部错误
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.iflytek.skillhub.exception;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;

@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {

@Mock
private SensitiveLogSanitizer sensitiveLogSanitizer;

@Mock
private SkillHubMetrics metrics;

@Mock
private HttpServletRequest request;

private GlobalExceptionHandler handler;

@BeforeEach
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
);
handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics);
}

@Test
void handleAsyncRequestTimeout_shouldReturnNoContentForSseRequests() {
when(request.getRequestURI()).thenReturn("/api/v1/notifications/sse");

ResponseEntity<?> response = handler.handleAsyncRequestTimeout(new AsyncRequestTimeoutException(), request);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(response.getBody()).isNull();
}

@Test
void handleAsyncRequestTimeout_shouldReturnApiEnvelopeForNonSseRequests() {
when(request.getRequestURI()).thenReturn("/api/v1/publish");
when(request.getMethod()).thenReturn("POST");
when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn("/api/v1/publish");

ResponseEntity<?> response = handler.handleAsyncRequestTimeout(new AsyncRequestTimeoutException(), request);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
assertThat(response.getBody()).isInstanceOf(ApiResponse.class);
ApiResponse<?> body = (ApiResponse<?>) response.getBody();
assertThat(body.code()).isEqualTo(408);
assertThat(body.msg()).isEqualTo("Request timed out");
}
}
Loading