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
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/com/example/blast_radius/infra/GroqApiException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.example.blast_radius.infra;

/**
* Thrown when the Groq API call fails due to network errors, HTTP errors,
* or an unparseable response envelope.
*/
public class GroqApiException extends Exception {

private final int httpStatus;

public GroqApiException(String message) {
super(message);
this.httpStatus = -1;
}

public GroqApiException(String message, int httpStatus) {
super(message);
this.httpStatus = httpStatus;
}

public GroqApiException(String message, Throwable cause) {
super(message, cause);
this.httpStatus = -1;
}

public int getHttpStatus() {
return httpStatus;
}
}
144 changes: 100 additions & 44 deletions src/main/java/com/example/blast_radius/infra/GroqClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;

@Component
public class GroqClient {

private static final Logger log = LoggerFactory.getLogger(GroqClient.class);

private final RestTemplate restTemplate;
private static final int MAX_ATTEMPTS = 2;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(20);

private final HttpClient httpClient;
private final ObjectMapper objectMapper;

@Value("${groq.api.key}")
Expand All @@ -30,59 +37,108 @@ public class GroqClient {
private String model;

public GroqClient() {
// TODO: Configure timeouts on RestTemplate (e.g., 30s connect, 60s read)
// SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// factory.setConnectTimeout(Duration.ofSeconds(30));
// factory.setReadTimeout(Duration.ofSeconds(60));
// this.restTemplate = new RestTemplate(factory);
this.restTemplate = new RestTemplate();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
this.objectMapper = new ObjectMapper();
}

/**
* 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).
*/
public String callChatApi(String promptPayload) throws Exception {
Map<String, Object> body = new HashMap<>();
body.put("model", model);
body.put("messages", new Object[]{
Map.of("role", "user", "content", promptPayload)
});
body.put("response_format", Map.of("type", "json_object"));

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);

HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);

log.debug("Calling Groq API at {} with model {}", apiUrl, model);

ResponseEntity<String> response =
restTemplate.exchange(apiUrl, HttpMethod.POST, entity, String.class);

if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("Groq API returned status " + response.getStatusCode());
public String callChatApi(String promptPayload) throws GroqApiException {
String requestBody = buildRequestBody(promptPayload);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(READ_TIMEOUT)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();

IOException lastIoException = null;
int lastStatusCode = -1;

for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());

int status = response.statusCode();

if (status >= 200 && status < 300) {
return extractContent(response.body());
}

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

// 5xx — server error, retry if attempts remain
lastStatusCode = status;
log.warn("Groq API returned HTTP {} (attempt {}/{})", status, attempt, MAX_ATTEMPTS);

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

String responseBody = response.getBody();
if (responseBody == null || responseBody.isBlank()) {
throw new RuntimeException("Groq API returned empty response body");
// All attempts exhausted
if (lastIoException != null) {
throw new GroqApiException(
"Groq API failed after " + MAX_ATTEMPTS + " attempts: " + lastIoException.getMessage(),
lastIoException);
}
throw new GroqApiException(
"Groq API returned HTTP " + lastStatusCode + " after " + MAX_ATTEMPTS + " attempts",
lastStatusCode);
}

JsonNode root = objectMapper.readTree(responseBody);
JsonNode choices = root.path("choices");

if (!choices.isArray() || choices.isEmpty()) {
throw new RuntimeException("Groq API response contained no choices");
private String buildRequestBody(String prompt) {
try {
Map<String, Object> body = Map.of(
"model", model,
"messages", new Object[]{
Map.of("role", "user", "content", prompt)
},
"response_format", Map.of("type", "json_object")
);
return objectMapper.writeValueAsString(body);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialize Groq request body", e);
}
}

String content = choices.get(0).path("message").path("content").asText();
if (content == null || content.isBlank()) {
throw new RuntimeException("Groq API returned empty message content");
private String extractContent(String responseBody) throws GroqApiException {
try {
JsonNode root = objectMapper.readTree(responseBody);
JsonNode choices = root.path("choices");

if (!choices.isArray() || choices.isEmpty()) {
throw new GroqApiException("Groq API response contained no choices");
}

String content = choices.get(0).path("message").path("content").asText();
if (content == null || content.isBlank()) {
throw new GroqApiException("Groq API returned empty message content");
}

return content;
} catch (GroqApiException e) {
throw e;
} catch (Exception e) {
throw new GroqApiException("Failed to parse Groq API response structure", e);
}

return content;
}
}
39 changes: 39 additions & 0 deletions src/main/java/com/example/blast_radius/model/OverallRisk.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.blast_radius.model;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
* Canonical risk levels returned by the analysis pipeline.
* LOW/MEDIUM/HIGH come from a successful LLM analysis.
* PARSING_ERROR and ERROR_UPSTREAM represent failure modes.
*/
public enum OverallRisk {

LOW,
MEDIUM,
HIGH,
PARSING_ERROR,
ERROR_UPSTREAM;

@JsonValue
public String toJson() {
return name();
}

/**
* Deserializes from the LLM's JSON string.
* Unrecognized values map to PARSING_ERROR rather than throwing.
*/
@JsonCreator
public static OverallRisk fromJson(String value) {
if (value == null || value.isBlank()) {
return PARSING_ERROR;
}
try {
return valueOf(value.strip().toUpperCase());
} catch (IllegalArgumentException e) {
return PARSING_ERROR;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package com.example.blast_radius.model;

import com.fasterxml.jackson.annotation.JsonInclude;

import java.util.List;

public class PrAnalysisResponse {
private String overallRisk;

private OverallRisk overallRisk;
private List<String> impactAreas;
private List<String> suggestedTests;

public String getOverallRisk() {
@JsonInclude(JsonInclude.Include.NON_NULL)
private String analysisId;

public OverallRisk getOverallRisk() {
return overallRisk;
}

public void setOverallRisk(String overallRisk) {
public void setOverallRisk(OverallRisk overallRisk) {
this.overallRisk = overallRisk;
}

Expand All @@ -30,4 +36,22 @@ public List<String> getSuggestedTests() {
public void setSuggestedTests(List<String> suggestedTests) {
this.suggestedTests = suggestedTests;
}

public String getAnalysisId() {
return analysisId;
}

public void setAnalysisId(String analysisId) {
this.analysisId = analysisId;
}

/** Factory for error/failure responses. */
public static PrAnalysisResponse error(OverallRisk risk, String analysisId) {
PrAnalysisResponse r = new PrAnalysisResponse();
r.setOverallRisk(risk);
r.setImpactAreas(List.of());
r.setSuggestedTests(List.of());
r.setAnalysisId(analysisId);
return r;
}
}
Loading
Loading