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
8 changes: 6 additions & 2 deletions .github/workflows/blast-radius.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@ jobs:
echo "Blast Radius API returned HTTP $HTTP_CODE"

if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "::error::Blast Radius API returned HTTP $HTTP_CODE"
echo "::warning::Blast Radius API unreachable (HTTP $HTTP_CODE) — posting advisory, not failing the PR"
cat response.json || true
exit 1
# Ephemeral dev service down (e.g. ngrok offline) → advisory comment instead of a red check.
cat > response.json <<'UNAVAIL'
{"overallRisk":"UNKNOWN","impactAreas":["Analysis service was unreachable — this check is advisory and does not block the PR."],"suggestedTests":[]}
UNAVAIL
exit 0
fi

# Validate that response.json is valid JSON
Expand Down
123 changes: 115 additions & 8 deletions src/main/java/com/example/blast_radius/infra/GroqClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.ThreadLocalRandom;

@Component
public class GroqClient {
Expand All @@ -24,8 +27,17 @@ public class GroqClient {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(20);

/** HTTP 429 — rate limited. A 4xx, but retryable (unlike other client errors). */
private static final int HTTP_TOO_MANY_REQUESTS = 429;

/** Base delay for exponential backoff; doubles each attempt, capped at the max. */
static final long DEFAULT_BASE_BACKOFF_MILLIS = 500L;
static final long DEFAULT_MAX_BACKOFF_MILLIS = 8_000L;

private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final long baseBackoffMillis;
private final long maxBackoffMillis;

@Value("${groq.api.key}")
private String apiKey;
Expand All @@ -41,13 +53,40 @@ public GroqClient() {
.connectTimeout(CONNECT_TIMEOUT)
.build();
this.objectMapper = new ObjectMapper();
this.baseBackoffMillis = DEFAULT_BASE_BACKOFF_MILLIS;
this.maxBackoffMillis = DEFAULT_MAX_BACKOFF_MILLIS;
}

/**
* Test-only constructor: injects a (mockable) HttpClient, the config values
* normally bound via {@code @Value}, and small backoff bounds so retry paths
* can be exercised without real-time sleeps.
*/
GroqClient(HttpClient httpClient, String apiUrl, String apiKey, String model,
long baseBackoffMillis, long maxBackoffMillis) {
this.httpClient = httpClient;
this.objectMapper = new ObjectMapper();
this.apiUrl = apiUrl;
this.apiKey = apiKey;
this.model = model;
this.baseBackoffMillis = baseBackoffMillis;
this.maxBackoffMillis = maxBackoffMillis;
}

/** The model name this client sends to Groq — the single source of truth for reporting. */
public String getModel() {
return model;
}

/**
* Sends a prompt to the Groq chat completions API and returns the assistant's
* message content as a raw string.
* Retries up to MAX_ATTEMPTS on network errors or 5xx responses.
* Does not retry on 4xx (client errors).
*
* <p>Retries up to {@link #MAX_ATTEMPTS} times on network errors, 5xx responses,
* and 429 (rate limit), with exponential backoff plus jitter between attempts.
* For 429, honors a {@code Retry-After} / {@code retry-after-ms} header when present.
* Other 4xx responses are not retried (they are client errors that will not succeed
* on retry).
*/
public String callChatApi(String promptPayload) throws GroqApiException {
String requestBody = buildRequestBody(promptPayload);
Expand All @@ -64,6 +103,7 @@ public String callChatApi(String promptPayload) throws GroqApiException {
int lastStatusCode = -1;

for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
long delayMillis;
try {
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
Expand All @@ -74,24 +114,35 @@ public String callChatApi(String promptPayload) throws GroqApiException {
return extractContent(response.body());
}

// 4xx — client error, do not retry
if (status >= 400 && status < 500) {
// 4xx other than 429 — client error, do not retry
if (status >= 400 && status < 500 && status != HTTP_TOO_MANY_REQUESTS) {
throw new GroqApiException(
"Groq API client error (HTTP " + status + ")", status);
}

// 5xx server error, retry if attempts remain
// Retryable: 5xx server error or 429 rate limit
lastStatusCode = status;
log.warn("Groq API returned HTTP {} (attempt {}/{})", status, attempt, MAX_ATTEMPTS);
long backoff = backoffMillis(attempt);
delayMillis = (status == HTTP_TOO_MANY_REQUESTS)
? retryAfterMillis(response).orElse(backoff)
: backoff;
log.warn("Groq API returned HTTP {} (attempt {}/{}); backing off {} ms",
status, attempt, MAX_ATTEMPTS, delayMillis);

} catch (IOException e) {
lastIoException = e;
log.warn("Groq API network error on attempt {}/{}: {}",
attempt, MAX_ATTEMPTS, e.getMessage());
delayMillis = backoffMillis(attempt);
log.warn("Groq API network error on attempt {}/{}: {}; backing off {} ms",
attempt, MAX_ATTEMPTS, e.getMessage(), delayMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GroqApiException("Groq API call interrupted", e);
}

// Back off before the next attempt; skip the sleep after the final attempt.
if (attempt < MAX_ATTEMPTS) {
sleep(delayMillis);
}
}

// All attempts exhausted
Expand All @@ -105,6 +156,62 @@ public String callChatApi(String promptPayload) throws GroqApiException {
lastStatusCode);
}

/**
* Exponential backoff with equal jitter: the delay for {@code attempt} is a
* random value in {@code [cap/2, cap]} where {@code cap = min(base * 2^(attempt-1), max)}.
* Jitter spreads retries so concurrent callers don't stampede a recovering upstream.
*/
private long backoffMillis(int attempt) {
long exponential = baseBackoffMillis * (1L << (attempt - 1));
long cap = Math.min(exponential, maxBackoffMillis);
long half = cap / 2;
return half + ThreadLocalRandom.current().nextLong(half + 1);
}

/**
* Parses a retry hint from the response. Prefers the OpenAI-style
* {@code retry-after-ms}, then standard {@code Retry-After} (seconds).
* HTTP-date forms are ignored (we fall back to backoff). Capped at maxBackoffMillis.
*/
private OptionalLong retryAfterMillis(HttpResponse<String> response) {
Optional<String> millis = response.headers().firstValue("retry-after-ms");
if (millis.isPresent()) {
OptionalLong parsed = parsePositiveLong(millis.get());
if (parsed.isPresent()) {
return OptionalLong.of(Math.min(parsed.getAsLong(), maxBackoffMillis));
}
}
Optional<String> seconds = response.headers().firstValue("retry-after");
if (seconds.isPresent()) {
OptionalLong parsed = parsePositiveLong(seconds.get());
if (parsed.isPresent()) {
return OptionalLong.of(Math.min(parsed.getAsLong() * 1000L, maxBackoffMillis));
}
}
return OptionalLong.empty();
}

private OptionalLong parsePositiveLong(String raw) {
try {
long value = Long.parseLong(raw.strip());
return value >= 0 ? OptionalLong.of(value) : OptionalLong.empty();
} catch (NumberFormatException e) {
return OptionalLong.empty();
}
}

private void sleep(long millis) throws GroqApiException {
if (millis <= 0) {
return;
}
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GroqApiException("Groq API retry backoff interrupted", e);
}
}

private String buildRequestBody(String prompt) {
try {
Map<String, Object> body = Map.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public class AnalysisService {
private static final int MAX_DIFF_LENGTH = 32_768;

private static final String PROMPT_VERSION = "v1.0.0";
private static final String MODEL_NAME = "qwen/qwen3-32b";

private final GroqClient groqClient;
private final Counter totalCounter;
Expand Down Expand Up @@ -66,10 +65,10 @@ public PrAnalysisResponse analyze(PrAnalysisRequest request) {
PrAnalysisResponse response = JsonParserUtil.toPrAnalysisResponse(rawResponse);
response.setAnalysisId(analysisId);
response.setPromptVersion(PROMPT_VERSION);
response.setModelName(MODEL_NAME);
response.setModelName(groqClient.getModel());
recordRisk(response.getOverallRisk());
log.info("[{}] Analysis complete — risk: {}, promptVersion={}, model={}",
analysisId, response.getOverallRisk(), PROMPT_VERSION, MODEL_NAME);
analysisId, response.getOverallRisk(), PROMPT_VERSION, groqClient.getModel());
return response;
} catch (Exception parseEx) {
log.warn("[{}] Failed to parse LLM response: {}. Raw (first 500 chars): {}",
Expand Down Expand Up @@ -236,7 +235,7 @@ private PrAnalysisResponse recordAndReturn(OverallRisk risk, String analysisId)
recordRisk(risk);
PrAnalysisResponse response = PrAnalysisResponse.error(risk, analysisId);
response.setPromptVersion(PROMPT_VERSION);
response.setModelName(MODEL_NAME);
response.setModelName(groqClient.getModel());
return response;
}

Expand Down
142 changes: 142 additions & 0 deletions src/test/java/com/example/blast_radius/infra/GroqClientTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.example.blast_radius.infra;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class GroqClientTest {

/** Matches MAX_ATTEMPTS in GroqClient. */
private static final int MAX_ATTEMPTS = 4;

private static final String VALID_BODY =
"{\"choices\":[{\"message\":{\"content\":\"{\\\"overallRisk\\\":\\\"LOW\\\"}\"}}]}";
private static final String EXPECTED_CONTENT = "{\"overallRisk\":\"LOW\"}";

private final HttpClient httpClient = mock(HttpClient.class);

/** Client wired with the mock transport and 1ms/2ms backoff so retries don't slow the test. */
private GroqClient client() {
return new GroqClient(httpClient, "http://groq.test/v1", "test-key", "test-model", 1L, 2L);
}

@SuppressWarnings("unchecked")
private HttpResponse<String> response(int status, String body, Map<String, List<String>> headers) {
HttpResponse<String> resp = mock(HttpResponse.class);
when(resp.statusCode()).thenReturn(status);
lenient().when(resp.body()).thenReturn(body);
lenient().when(resp.headers()).thenReturn(HttpHeaders.of(headers, (a, b) -> true));
return resp;
}

private HttpResponse<String> ok() {
return response(200, VALID_BODY, Map.of());
}

@Test
void returnsContent_on2xx() throws Exception {
HttpResponse<String> ok = ok();
when(httpClient.<String>send(any(), any())).thenReturn(ok);

String content = client().callChatApi("prompt");

assertEquals(EXPECTED_CONTENT, content);
verify(httpClient, times(1)).send(any(), any());
}

@Test
void retriesOn5xx_thenSucceeds() throws Exception {
HttpResponse<String> error = response(503, "", Map.of());
HttpResponse<String> ok = ok();
when(httpClient.<String>send(any(), any())).thenReturn(error).thenReturn(ok);

String content = client().callChatApi("prompt");

assertEquals(EXPECTED_CONTENT, content);
verify(httpClient, times(2)).send(any(), any());
}

@Test
void retriesOn429_thenSucceeds() throws Exception {
HttpResponse<String> rateLimited = response(429, "", Map.of());
HttpResponse<String> ok = ok();
when(httpClient.<String>send(any(), any())).thenReturn(rateLimited).thenReturn(ok);

String content = client().callChatApi("prompt");

assertEquals(EXPECTED_CONTENT, content);
verify(httpClient, times(2)).send(any(), any());
}

@Test
void honorsRetryAfterHeader_on429() throws Exception {
// retry-after-ms present — must be parsed and not break the retry path
HttpResponse<String> rateLimited = response(429, "", Map.of("retry-after-ms", List.of("1")));
HttpResponse<String> ok = ok();
when(httpClient.<String>send(any(), any())).thenReturn(rateLimited).thenReturn(ok);

String content = client().callChatApi("prompt");

assertEquals(EXPECTED_CONTENT, content);
verify(httpClient, times(2)).send(any(), any());
}

@Test
void doesNotRetryOn4xx() throws Exception {
HttpResponse<String> clientError = response(400, "", Map.of());
when(httpClient.<String>send(any(), any())).thenReturn(clientError);

GroqApiException ex = assertThrows(GroqApiException.class, () -> client().callChatApi("prompt"));

assertEquals(400, ex.getHttpStatus());
verify(httpClient, times(1)).send(any(), any());
}

@Test
void throwsAfterExhausting5xx() throws Exception {
HttpResponse<String> error = response(503, "", Map.of());
when(httpClient.<String>send(any(), any())).thenReturn(error);

GroqApiException ex = assertThrows(GroqApiException.class, () -> client().callChatApi("prompt"));

assertEquals(503, ex.getHttpStatus());
verify(httpClient, times(MAX_ATTEMPTS)).send(any(), any());
}

@Test
void retriesOnIOException_thenSucceeds() throws Exception {
HttpResponse<String> ok = ok();
when(httpClient.<String>send(any(), any()))
.thenThrow(new IOException("connection reset"))
.thenReturn(ok);

String content = client().callChatApi("prompt");

assertEquals(EXPECTED_CONTENT, content);
verify(httpClient, times(2)).send(any(), any());
}

@Test
void throwsAfterExhaustingIOExceptions() throws Exception {
when(httpClient.<String>send(any(), any())).thenThrow(new IOException("connection reset"));

GroqApiException ex = assertThrows(GroqApiException.class, () -> client().callChatApi("prompt"));

verify(httpClient, times(MAX_ATTEMPTS)).send(any(), any());
assertEquals(-1, ex.getHttpStatus(), "Network-failure exception carries no HTTP status");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ void analyze_returnsParsedResponse_onHappyPath() throws Exception {
assertRiskCounterIncremented("LOW", 1);
}

// ── Metadata ──────────────────────────────────────────────────────

@Test
void analyze_setsModelNameFromGroqClient() throws Exception {
String validJson = "{\"overallRisk\":\"LOW\",\"impactAreas\":[],\"suggestedTests\":[]}";
when(groqClient.callChatApi(anyString())).thenReturn(validJson);
when(groqClient.getModel()).thenReturn("configured-model");

PrAnalysisResponse response =
analysisService.analyze(requestWithDiff("diff --git a/Foo.java b/Foo.java"));

assertEquals("configured-model", response.getModelName());
assertEquals("v1.0.0", response.getPromptVersion());
}

// ── Helpers ───────────────────────────────────────────────────────

private PrAnalysisRequest requestWithDiff(String diff) {
Expand Down
Loading