From f7a13d62c4c1cffa83278d689678a38d3d872d6d Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:41:30 -0300 Subject: [PATCH 1/6] test(ai): specify Gemini rate-limit error mapping --- .../AssistantProviderErrorMapperTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/test/java/com/jobtracker/unit/AssistantProviderErrorMapperTest.java diff --git a/src/test/java/com/jobtracker/unit/AssistantProviderErrorMapperTest.java b/src/test/java/com/jobtracker/unit/AssistantProviderErrorMapperTest.java new file mode 100644 index 0000000..f8998e5 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/AssistantProviderErrorMapperTest.java @@ -0,0 +1,56 @@ +package com.jobtracker.unit; + +import com.google.genai.errors.ApiException; +import com.jobtracker.service.assistant.AssistantProviderErrorMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AssistantProviderErrorMapperTest { + private final AssistantProviderErrorMapper mapper = new AssistantProviderErrorMapper(); + + @Test + void mapsGeminiRateLimitWithProviderRetryDelay() { + RuntimeException error = new RuntimeException( + "Failed to generate content", + new ApiException( + 429, + "RESOURCE_EXHAUSTED", + "Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests. Please retry in 35.878391973s." + ) + ); + + var payload = mapper.map(error); + + assertThat(payload.code()).isEqualTo("RATE_LIMITED"); + assertThat(payload.message()).isEqualTo("Gemini rate limit exceeded"); + assertThat(payload.retryAfterSeconds()).isEqualTo(36); + } + + @Test + void doesNotInventRetryDelayWhenGeminiOmitsIt() { + RuntimeException error = new RuntimeException( + "Failed to generate content", + new ApiException(429, "RESOURCE_EXHAUSTED", "Quota exceeded for this project") + ); + + var payload = mapper.map(error); + + assertThat(payload.code()).isEqualTo("RATE_LIMITED"); + assertThat(payload.retryAfterSeconds()).isNull(); + } + + @Test + void mapsNonRateLimitProviderFailuresToGenericUnavailable() { + RuntimeException error = new RuntimeException( + "Failed to generate content", + new ApiException(503, "UNAVAILABLE", "Service unavailable") + ); + + var payload = mapper.map(error); + + assertThat(payload.code()).isEqualTo("PROVIDER_UNAVAILABLE"); + assertThat(payload.message()).isEqualTo("Assistant provider is unavailable"); + assertThat(payload.retryAfterSeconds()).isNull(); + } +} From ac34d4015204aff4dbec44129c7babec761aa921 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:41:33 -0300 Subject: [PATCH 2/6] test(ai): specify structured assistant SSE errors --- .../unit/AssistantControllerTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/test/java/com/jobtracker/unit/AssistantControllerTest.java diff --git a/src/test/java/com/jobtracker/unit/AssistantControllerTest.java b/src/test/java/com/jobtracker/unit/AssistantControllerTest.java new file mode 100644 index 0000000..c8b4eec --- /dev/null +++ b/src/test/java/com/jobtracker/unit/AssistantControllerTest.java @@ -0,0 +1,62 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.AssistantProperties; +import com.jobtracker.controller.AssistantController; +import com.jobtracker.dto.assistant.AssistantErrorPayload; +import com.jobtracker.service.assistant.AssistantProviderErrorMapper; +import com.jobtracker.service.assistant.AssistantService; +import com.jobtracker.service.assistant.AssistantService.AssistantStream; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import reactor.core.publisher.Flux; + +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class AssistantControllerTest { + + @Test + void writesStructuredProviderErrorsToTheSseStream() throws Exception { + AssistantService assistant = mock(AssistantService.class); + AssistantProviderErrorMapper errorMapper = mock(AssistantProviderErrorMapper.class); + AssistantProperties properties = new AssistantProperties(); + + when(assistant.stream(any(UUID.class), eq("hello"))) + .thenReturn(new AssistantStream(Flux.error(new IllegalStateException("provider failed")), Set::of)); + when(errorMapper.map(any())) + .thenReturn(new AssistantErrorPayload("PROVIDER_UNAVAILABLE", "Assistant provider is unavailable", null)); + + MockMvc mockMvc = standaloneSetup(new AssistantController(assistant, properties, errorMapper)).build(); + + MvcResult result = mockMvc.perform(post("/api/v1/assistant/chat") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.TEXT_EVENT_STREAM) + .content(""" + { + "conversationId": "5c970c0e-d6b9-4e3c-8120-f8d496d654a5", + "message": "hello" + } + """)) + .andExpect(request().asyncStarted()) + .andReturn(); + + result.getAsyncResult(1_000); + String body = result.getResponse().getContentAsString(); + + assertThat(body).contains("event:error"); + assertThat(body).contains("\"code\":\"PROVIDER_UNAVAILABLE\""); + assertThat(body).contains("\"message\":\"Assistant provider is unavailable\""); + assertThat(body).doesNotContain("retryAfterSeconds"); + } +} From a1b700c5a1153fb23d5bb9ac987c4e9874cb3fb3 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:43:06 -0300 Subject: [PATCH 3/6] feat(ai): add structured assistant error payload --- .../dto/assistant/AssistantErrorPayload.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/jobtracker/dto/assistant/AssistantErrorPayload.java diff --git a/src/main/java/com/jobtracker/dto/assistant/AssistantErrorPayload.java b/src/main/java/com/jobtracker/dto/assistant/AssistantErrorPayload.java new file mode 100644 index 0000000..215e257 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/assistant/AssistantErrorPayload.java @@ -0,0 +1,10 @@ +package com.jobtracker.dto.assistant; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AssistantErrorPayload( + String code, + String message, + Integer retryAfterSeconds +) {} From 5e9474cb3c7b228b908ec5a1b383e6f578bbb8ab Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:43:08 -0300 Subject: [PATCH 4/6] feat(ai): map Gemini rate-limit failures --- .../AssistantProviderErrorMapper.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/com/jobtracker/service/assistant/AssistantProviderErrorMapper.java diff --git a/src/main/java/com/jobtracker/service/assistant/AssistantProviderErrorMapper.java b/src/main/java/com/jobtracker/service/assistant/AssistantProviderErrorMapper.java new file mode 100644 index 0000000..6a90cc0 --- /dev/null +++ b/src/main/java/com/jobtracker/service/assistant/AssistantProviderErrorMapper.java @@ -0,0 +1,64 @@ +package com.jobtracker.service.assistant; + +import com.google.genai.errors.ApiException; +import com.jobtracker.dto.assistant.AssistantErrorPayload; +import org.springframework.stereotype.Component; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class AssistantProviderErrorMapper { + private static final Pattern RETRY_DELAY = Pattern.compile( + "(?i)\\bretry\\s+in\\s+([0-9]+(?:\\.[0-9]+)?)\\s*s\\b" + ); + + public AssistantErrorPayload map(Throwable error) { + ApiException apiException = findApiException(error); + if (apiException != null && isRateLimited(apiException)) { + return new AssistantErrorPayload( + "RATE_LIMITED", + "Gemini rate limit exceeded", + extractRetryAfterSeconds(apiException) + ); + } + + return new AssistantErrorPayload( + "PROVIDER_UNAVAILABLE", + "Assistant provider is unavailable", + null + ); + } + + private boolean isRateLimited(ApiException error) { + if (error.code() == 429) return true; + if ("RESOURCE_EXHAUSTED".equalsIgnoreCase(error.status())) return true; + + String message = error.message(); + if (message == null) return false; + String normalized = message.toLowerCase(Locale.ROOT); + return normalized.contains("quota exceeded") || normalized.contains("rate limit"); + } + + private Integer extractRetryAfterSeconds(ApiException error) { + String message = error.message(); + if (message == null || message.isBlank()) return null; + + Matcher matcher = RETRY_DELAY.matcher(message); + if (!matcher.find()) return null; + + double seconds = Double.parseDouble(matcher.group(1)); + if (!Double.isFinite(seconds) || seconds < 0) return null; + return (int) Math.ceil(seconds); + } + + private ApiException findApiException(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof ApiException apiException) return apiException; + current = current.getCause(); + } + return null; + } +} From b8c2e0affc4edad89871a25e3218343416311e5e Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:43:11 -0300 Subject: [PATCH 5/6] feat(ai): emit structured provider errors over SSE --- .../java/com/jobtracker/controller/AssistantController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobtracker/controller/AssistantController.java b/src/main/java/com/jobtracker/controller/AssistantController.java index 06c5e6b..8c0fa64 100644 --- a/src/main/java/com/jobtracker/controller/AssistantController.java +++ b/src/main/java/com/jobtracker/controller/AssistantController.java @@ -2,7 +2,7 @@ import com.jobtracker.config.AssistantProperties; import com.jobtracker.dto.assistant.AssistantChatRequest; -import com.jobtracker.service.assistant.AssistantService; +import com.jobtracker.service.assistant.AssistantProviderErrorMapper;\nimport com.jobtracker.service.assistant.AssistantService; import com.jobtracker.service.assistant.AssistantService.AssistantStream; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -42,7 +42,7 @@ public SseEmitter chat(@Valid @RequestBody AssistantChatRequest request) { subscription.set(stream.content().subscribe( token -> send(emitter, "token", Map.of("content", token)), error -> { - send(emitter, "error", Map.of("message", "Assistant provider is unavailable")); + send(emitter, "error", errorMapper.map(error)); emitter.complete(); }, () -> { From 759e899076a4b49834a08875785b618d57063ee5 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Thu, 17 Sep 2026 20:44:52 -0300 Subject: [PATCH 6/6] fix(ai): wire provider error mapper into controller --- .../com/jobtracker/controller/AssistantController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobtracker/controller/AssistantController.java b/src/main/java/com/jobtracker/controller/AssistantController.java index 8c0fa64..56e36fe 100644 --- a/src/main/java/com/jobtracker/controller/AssistantController.java +++ b/src/main/java/com/jobtracker/controller/AssistantController.java @@ -2,7 +2,8 @@ import com.jobtracker.config.AssistantProperties; import com.jobtracker.dto.assistant.AssistantChatRequest; -import com.jobtracker.service.assistant.AssistantProviderErrorMapper;\nimport com.jobtracker.service.assistant.AssistantService; +import com.jobtracker.service.assistant.AssistantProviderErrorMapper; +import com.jobtracker.service.assistant.AssistantService; import com.jobtracker.service.assistant.AssistantService.AssistantStream; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -22,10 +23,13 @@ public class AssistantController { private final AssistantService assistant; private final AssistantProperties properties; + private final AssistantProviderErrorMapper errorMapper; - public AssistantController(AssistantService assistant, AssistantProperties properties) { + public AssistantController(AssistantService assistant, AssistantProperties properties, + AssistantProviderErrorMapper errorMapper) { this.assistant = assistant; this.properties = properties; + this.errorMapper = errorMapper; } @PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')")