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 @@ -2,6 +2,7 @@

import com.jobtracker.config.AssistantProperties;
import com.jobtracker.dto.assistant.AssistantChatRequest;
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;
Expand All @@ -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')")
Expand All @@ -42,7 +46,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();
},
() -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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;
}
}
62 changes: 62 additions & 0 deletions src/test/java/com/jobtracker/unit/AssistantControllerTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading