diff --git a/docs/API-feat6.md b/docs/API-feat6.md index 86deba49..25a4fef5 100644 --- a/docs/API-feat6.md +++ b/docs/API-feat6.md @@ -53,6 +53,16 @@ Response body: } ``` +**Response 503 SERVICE UNAVAILABLE** + +Response body: +```json +{ + "error": "SERVICE_UNAVAILABLE", + "message": "Our AI service is currently unavailable. Please try again later." +} +``` + **Path:** `api/resume-ai-advisor/remaining-quota` **Method:** `GET` diff --git a/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java new file mode 100644 index 00000000..92507e93 --- /dev/null +++ b/src/main/java/com/backend/coapp/exception/genai/GenAIOutOfServiceException.java @@ -0,0 +1,8 @@ +package com.backend.coapp.exception.genai; + +/** This exception will be thrown when we reach usage limit. User need to try again later. */ +public class GenAIOutOfServiceException extends RuntimeException { + public GenAIOutOfServiceException(String message) { + super("Our AI service failure. " + message); + } +} diff --git a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java index 043d7f7a..b6dbae6e 100644 --- a/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/coapp/handler/GlobalExceptionHandler.java @@ -25,7 +25,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import tools.jackson.databind.exc.InvalidFormatException; -/** Exception handler for controller. */ +/** + * Exception handler for controller. + * + *

Internal exceptions (5** HTTP status) will be logged for debugging. + */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @@ -63,7 +67,7 @@ public ResponseEntity> handleEmailServiceException(EmailServ @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException ex) { - String errorMessage = "ERROR: Reset verification code service failed: " + ex.getMessage(); + String errorMessage = "ERROR: Runtime exception: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -112,7 +116,7 @@ public ResponseEntity> handleAuthAccountAlreadyVerifyExcepti @ExceptionHandler(AuthenticationException.class) public ResponseEntity> handleAuthenticationException( AuthenticationException ex) { - String errorMessage = "ERROR: JWT Service failed: " + ex.getMessage(); + String errorMessage = "ERROR: Authentication Service failed: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -333,7 +337,7 @@ public ResponseEntity> handleConcurrencyException(Concurrenc @ExceptionHandler(GenAIUsageManagementServiceException.class) public ResponseEntity> handleGenAIUsageManagementServiceException( GenAIUsageManagementServiceException ex) { - String errorMessage = "ERROR: Application Service failed: " + ex.getMessage(); + String errorMessage = "ERROR: GenAI Service failed: " + ex.getMessage(); log.error(errorMessage); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( @@ -385,4 +389,33 @@ public ResponseEntity> handleHttpMessageNotReadable( return ResponseEntity.badRequest() .body(Map.of("error", RequestErrorCode.INVALID_FORMAT_FIELD.name(), "message", message)); } + + @ExceptionHandler(GenAIOutOfServiceException.class) + public ResponseEntity> handleGenAIOutOfServiceException( + GenAIOutOfServiceException ex) { + + String errorMessage = "ERROR: Resume workshop Service failed: " + ex.getMessage(); + log.error(errorMessage); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + Map.of( + "error", + GenAIErrorCode.SERVICE_UNAVAILABLE, + "message", + "Our AI service is currently unavailable. Please try again later.")); + } + + @ExceptionHandler(GenAIServiceException.class) + public ResponseEntity> handleGenAIServiceException(GenAIServiceException ex) { + + String errorMessage = "ERROR: GenAI Service failed: " + ex.getMessage(); + log.error(errorMessage); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + Map.of( + "error", + SystemErrorCode.INTERNAL_ERROR, + "message", + "GenAI Service failed. Please try again later.")); + } } diff --git a/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java b/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java index 5e728be6..98e01b45 100644 --- a/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java +++ b/src/main/java/com/backend/coapp/model/enumeration/GenAIErrorCode.java @@ -3,5 +3,6 @@ public enum GenAIErrorCode { OVER_LIMIT_CHARACTER, OVER_LIMIT_CHATBOT_REQUEST, - OTHER_REQUEST_IN_PROGRESS + OTHER_REQUEST_IN_PROGRESS, + SERVICE_UNAVAILABLE } diff --git a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java index 2c6ea8d4..dcbd90d8 100644 --- a/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java +++ b/src/main/java/com/backend/coapp/service/GenAIResumeAdvisorService.java @@ -2,10 +2,7 @@ import com.backend.coapp.exception.application.ApplicationNotFoundException; import com.backend.coapp.exception.application.ApplicationNotOwnedException; -import com.backend.coapp.exception.genai.ConcurrencyException; -import com.backend.coapp.exception.genai.GenAIQuotaExceededException; -import com.backend.coapp.exception.genai.GenAIUsageManagementServiceException; -import com.backend.coapp.exception.genai.OverCharacterLimitException; +import com.backend.coapp.exception.genai.*; import com.backend.coapp.exception.global.UserNotFoundException; import com.backend.coapp.model.document.ApplicationModel; import com.backend.coapp.model.document.UserExperienceModel; @@ -57,6 +54,8 @@ public GenAIResumeAdvisorService( * @throws UserNotFoundException when user doesn't exist * @throws GenAIQuotaExceededException when user exceed GenAI usage limit * @throws ConcurrencyException when the same user make a request twice + * @throws GenAIOutOfServiceException when we reach usage limit (internally) + * @throws GenAIServiceException when there is something wrong for GenAI service (internally) */ public String getAdvice(String userId, String applicationId, String prompt) throws OverCharacterLimitException, @@ -65,7 +64,9 @@ public String getAdvice(String userId, String applicationId, String prompt) GenAIUsageManagementServiceException, UserNotFoundException, GenAIQuotaExceededException, - ConcurrencyException { + ConcurrencyException, + GenAIOutOfServiceException, + GenAIServiceException { String applicationJobDescription = null; String applicationJobTitle = null; if (prompt.length() > GenAIConstants.MAX_PROMPT_CHARACTERS) { diff --git a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java index 203f6999..35056811 100644 --- a/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java +++ b/src/main/java/com/backend/coapp/service/genAI/GeminiGenAIService.java @@ -1,16 +1,21 @@ package com.backend.coapp.service.genAI; +import com.backend.coapp.exception.genai.GenAIOutOfServiceException; import com.backend.coapp.exception.genai.GenAIServiceException; import com.backend.coapp.exception.genai.OverCharacterLimitException; import com.backend.coapp.util.GenAIConstants; import com.google.genai.Client; +import com.google.genai.errors.ApiException; import com.google.genai.types.GenerateContentResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service +@Slf4j @ConditionalOnProperty(name = "gen-ai.provider", havingValue = "gemini") public class GeminiGenAIService implements GenAIService { private final Client geminiClient; @@ -29,11 +34,12 @@ public GeminiGenAIService(Client geminiClient) { * @param prompt client's prompt * @return GenAI response * @throws IllegalArgumentException when invalid input - * @throws GenAIServiceException when there is something wrong with GenAI provider. + * @throws GenAIServiceException when there is something wrong with GenAI provider + * @throws GenAIOutOfServiceException when we reach usage limit (internally) */ @Override public String generateResponse(String prompt) - throws IllegalArgumentException, GenAIServiceException { + throws IllegalArgumentException, GenAIServiceException, GenAIOutOfServiceException { if (prompt == null || prompt.isBlank()) { throw new IllegalArgumentException("Prompt can't be null or blank"); } @@ -47,6 +53,12 @@ public String generateResponse(String prompt) GenerateContentResponse response = geminiClient.models.generateContent(this.model, prompt, null); return response.text(); + } catch (ApiException e) { + if (e.code() == HttpStatus.TOO_MANY_REQUESTS.value() || (e.code() >= 500 && e.code() < 600)) { + // This includes 503 - SERVICE UNAVAILABLE and 500 - INTERNAL ERROR + throw new GenAIOutOfServiceException(e.getMessage()); + } + throw new GenAIServiceException(e.getMessage()); } catch (Exception e) { throw new GenAIServiceException(e.getMessage()); } diff --git a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java index ba143ba1..7edbbe20 100644 --- a/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java +++ b/src/test/java/com/backend/coapp/controller/GenAIResumeAdvisorControllerTest.java @@ -11,12 +11,10 @@ import com.backend.coapp.dto.request.GenAIResumeAdvisorRequest; import com.backend.coapp.exception.application.ApplicationNotFoundException; import com.backend.coapp.exception.application.ApplicationNotOwnedException; -import com.backend.coapp.exception.genai.ConcurrencyException; -import com.backend.coapp.exception.genai.GenAIQuotaExceededException; -import com.backend.coapp.exception.genai.GenAIUsageManagementServiceException; -import com.backend.coapp.exception.genai.OverCharacterLimitException; +import com.backend.coapp.exception.genai.*; import com.backend.coapp.exception.global.UserNotFoundException; import com.backend.coapp.model.document.UserModel; +import com.backend.coapp.model.enumeration.GenAIErrorCode; import com.backend.coapp.model.enumeration.SystemErrorCode; import com.backend.coapp.model.enumeration.UserErrorCode; import com.backend.coapp.service.GenAIResumeAdvisorService; @@ -256,6 +254,42 @@ void resumeAdvisor_whenServiceFails_expect500() throws Exception { .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); } + @Test + void resumeAdvisor_whenGenAIServiceFails_expect500() throws Exception { + when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) + .thenThrow(new GenAIServiceException("Internal error")); + + mockMvc + .perform( + post("/api/resume-ai-advisor") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest)) + .principal(this.authentication)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error").value(SystemErrorCode.INTERNAL_ERROR.name())); + + verify(genAIResumeAdvisorService, times(1)) + .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); + } + + @Test + void resumeAdvisor_whenReachLimit_expect503() throws Exception { + when(genAIResumeAdvisorService.getAdvice(anyString(), any(), anyString())) + .thenThrow(new GenAIOutOfServiceException("foo exception")); + + mockMvc + .perform( + post("/api/resume-ai-advisor") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest)) + .principal(this.authentication)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error").value(GenAIErrorCode.SERVICE_UNAVAILABLE.name())); + + verify(genAIResumeAdvisorService, times(1)) + .getAdvice(mockUser.getId(), validRequest.getApplicationId(), validRequest.getUserPrompt()); + } + @Test void getRemainingQuota_whenSuccess_expectOkAndRemainingQuota() throws Exception { when(genAIUsageManagementService.getNumberOfRequestLeft(anyString())).thenReturn(7); diff --git a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java index a8fbaa67..bc268de1 100644 --- a/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java +++ b/src/test/java/com/backend/coapp/service/genAI/GeminiGenAIServiceTest.java @@ -5,11 +5,13 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; +import com.backend.coapp.exception.genai.GenAIOutOfServiceException; import com.backend.coapp.exception.genai.GenAIServiceException; import com.backend.coapp.exception.genai.OverCharacterLimitException; import com.backend.coapp.util.GenAIConstants; import com.google.genai.Client; import com.google.genai.Models; +import com.google.genai.errors.ApiException; import com.google.genai.types.GenerateContentResponse; import java.lang.reflect.Field; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +20,7 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; +import org.springframework.http.HttpStatus; class GeminiGenAIServiceTest { @@ -90,4 +93,63 @@ void generateResponse_whenPromptExceedsMaxCharacters_expectOverCharacterLimitExc verifyNoInteractions(models); assertNotNull(ex); } + + @Test + void generateResponse_whenGeminiClientThrows429_expectGenAIOutOfServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow( + new ApiException( + HttpStatus.TOO_MANY_REQUESTS.value(), + HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), + "Please try again")); + + GenAIOutOfServiceException ex = + assertThrows( + GenAIOutOfServiceException.class, + () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {429, 503, 500}) + void generateResponse_whenGeminiClientThrowsOutOfServiceStatus_expectGenAIOutOfServiceException( + int statusCode) { + HttpStatus httpStatus = HttpStatus.valueOf(statusCode); + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new ApiException(statusCode, httpStatus.getReasonPhrase(), "Please try again")); + + GenAIOutOfServiceException ex = + assertThrows( + GenAIOutOfServiceException.class, + () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 600}) + void generateResponse_whenGeminiClientThrowsNonOutOfServiceStatus_expectGenAIServiceException( + int statusCode) { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new ApiException(statusCode, "Error", "Something went wrong")); + + GenAIServiceException ex = + assertThrows( + GenAIServiceException.class, () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } + + @Test + void generateResponse_whenGeminiClientThrowsRuntimeException_expectGenAIServiceException() { + when(models.generateContent(eq(MODEL), eq(VALID_PROMPT), isNull())) + .thenThrow(new RuntimeException()); + + GenAIServiceException ex = + assertThrows( + GenAIServiceException.class, () -> geminiGenAIService.generateResponse(VALID_PROMPT)); + + assertNotNull(ex.getMessage()); + } }