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 @@ -8,6 +8,8 @@
@ConfigurationProperties(prefix = "app.ai-runtime")
public final class AiRuntimeProperties implements AiRuntimeDeadlinePolicy {

private static final String ANALYSIS_ENDPOINT_PATH = "/internal/v1/analyses";
private static final String RENEWAL_ENDPOINT_PATH = "/internal/v1/workflows/renewal/run";
private static final int MIN_RESPONSE_BYTES = 1_024;
private static final int MAX_RESPONSE_BYTES = 10 * 1_024 * 1_024;
private static final Duration MAX_OVERALL_TIMEOUT = Duration.ofMinutes(5);
Expand Down Expand Up @@ -180,6 +182,8 @@ String authorizationHeader() {
void validateEnabledConfiguration() {
requireHttpEndpoint(endpoint);
requireHttpEndpoint(renewalEndpoint);
requireEndpointPath(endpoint, ANALYSIS_ENDPOINT_PATH, "AI_RUNTIME_ENDPOINT");
requireEndpointPath(renewalEndpoint, RENEWAL_ENDPOINT_PATH, "AI_RUNTIME_RENEWAL_ENDPOINT");
requireHttpEndpoint(documentGenerationEndpoint);
requireHttpEndpoint(documentConversionEndpoint);
authorizationHeader();
Expand All @@ -188,6 +192,16 @@ void validateEnabledConfiguration() {
requirePositive(documentConversionTimeout, "documentConversionTimeout");
}

private static void requireEndpointPath(URI endpoint, String expectedSuffix, String propertyName) {
String path = endpoint.getPath();
String normalizedPath = path != null && path.endsWith("/")
? path.substring(0, path.length() - 1)
: path;
if (normalizedPath == null || !normalizedPath.endsWith(expectedSuffix)) {
throw new IllegalStateException(propertyName + " must end with " + expectedSuffix);
}
}

private static URI requireHttpEndpoint(URI value) {
if (value == null
|| !value.isAbsolute()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,28 @@
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;

/** One transport attempt against the Agent-owned Renewal endpoint. */
public final class RemoteRenewalRuntimeClient implements RenewalRuntimeClient {

private static final Logger log = LoggerFactory.getLogger(RemoteRenewalRuntimeClient.class);
private static final int MAX_VALIDATION_FIELDS = 10;
private static final Set<AiRuntimeFailureCode> CIRCUIT_FAILURES = EnumSet.of(
AiRuntimeFailureCode.DEADLINE_EXCEEDED,
AiRuntimeFailureCode.RATE_LIMITED,
Expand Down Expand Up @@ -89,7 +97,7 @@ public RenewalRunResponse run(RenewalRunRequest request, AiRuntimeCallContext co
if (context.traceParent() != null) {
builder.header("traceparent", context.traceParent());
}
RenewalRunResponse response = decode(execute(builder.build()));
RenewalRunResponse response = decode(execute(builder.build()), request.requestId());
circuitBreaker.recordSuccess();
return response;
} catch (AiRuntimeCallException exception) {
Expand Down Expand Up @@ -134,10 +142,10 @@ private HttpResponse<byte[]> execute(HttpRequest request) {
}
}

private RenewalRunResponse decode(HttpResponse<byte[]> response) {
private RenewalRunResponse decode(HttpResponse<byte[]> response, UUID requestId) {
int status = response.statusCode();
if (status < 200 || status >= 300) {
throw classifyStatus(status);
throw classifyStatus(response, requestId);
}
try {
return objectMapper.readValue(response.body(), RenewalRunResponse.class);
Expand Down Expand Up @@ -173,7 +181,8 @@ private AiRuntimeCallException classifyExecutionFailure(Throwable cause) {
return failure(AiRuntimeFailureCode.TRANSPORT_FAILURE, "AI Renewal transport failed.", failure);
}

private AiRuntimeCallException classifyStatus(int status) {
private AiRuntimeCallException classifyStatus(HttpResponse<byte[]> response, UUID requestId) {
int status = response.statusCode();
if (status == 408) {
return failure(AiRuntimeFailureCode.DEADLINE_EXCEEDED, "AI Renewal deadline was exceeded.");
}
Expand All @@ -183,12 +192,63 @@ private AiRuntimeCallException classifyStatus(int status) {
if (status == 429) {
return failure(AiRuntimeFailureCode.RATE_LIMITED, "AI Renewal rate limit was reached.");
}
if (status >= 500) {
if (status == 404 || status == 405 || status >= 500) {
return failure(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, "AI Renewal is unavailable.");
}
if (status == 400 || status == 422) {
List<String> validationFields = safeValidationFields(response.body());
log.warn(
"event=ai_renewal_request_rejected request_id={} upstream_status={} validation_fields={}",
requestId,
status,
validationFields
);
}
return failure(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Renewal rejected the request contract.");
}

/** Extracts only schema locations and validation types; values and Provider messages are never logged. */
private List<String> safeValidationFields(byte[] responseBody) {
try {
JsonNode detail = objectMapper.readTree(responseBody).path("detail");
if (!detail.isArray()) {
return List.of();
}
List<String> fields = new ArrayList<>();
for (JsonNode validation : detail) {
if (fields.size() >= MAX_VALIDATION_FIELDS) {
break;
}
JsonNode location = validation.path("loc");
if (!location.isArray()) {
continue;
}
List<String> segments = new ArrayList<>();
for (JsonNode segment : location) {
String value = safeToken(segment.asString(""));
if (!value.isBlank() && !"body".equals(value)) {
segments.add(value);
}
}
if (!segments.isEmpty()) {
String type = safeToken(validation.path("type").asString("invalid"));
fields.add(String.join(".", segments) + ":" + type);
}
}
return List.copyOf(fields);
} catch (JacksonException exception) {
return List.of();
}
}

private String safeToken(String value) {
if (value == null || value.isBlank()) {
return "";
}
String sanitized = value.replaceAll("[^A-Za-z0-9_-]", "");
return sanitized.substring(0, Math.min(sanitized.length(), 64));
}

private Throwable unwrap(Throwable throwable) {
Throwable current = throwable;
while ((current instanceof ExecutionException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -35,4 +36,29 @@ void rejectsOverallTimeoutLongerThanContractMaximum() {
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("overallTimeout must not exceed 5m");
}

@Test
void rejectsAnalysisEndpointConfiguredAsRenewalEndpoint() {
AiRuntimeProperties properties = new AiRuntimeProperties();
properties.setServiceCredential("test-token");
properties.setRenewalEndpoint(URI.create("http://ai:8000/internal/v1/analyses"));

assertThatThrownBy(properties::validateEnabledConfiguration)
.isInstanceOf(IllegalStateException.class)
.hasMessage(
"AI_RUNTIME_RENEWAL_ENDPOINT must end with /internal/v1/workflows/renewal/run"
);
}

@Test
void acceptsDeploymentEndpointsWithAProxyPrefix() {
AiRuntimeProperties properties = new AiRuntimeProperties();
properties.setServiceCredential("test-token");
properties.setEndpoint(URI.create("https://agent.example.com/fowoco/internal/v1/analyses"));
properties.setRenewalEndpoint(URI.create(
"https://agent.example.com/fowoco/internal/v1/workflows/renewal/run/"
));

properties.validateEnabledConfiguration();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException;
import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode;
import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext;
import com.fowoco.server.aiintegration.application.renewal.RenewalCompanySnapshot;
import com.fowoco.server.aiintegration.application.renewal.RenewalRunRequest;
Expand All @@ -25,6 +31,7 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
Expand Down Expand Up @@ -59,6 +66,9 @@ void sendsTheAgentOwnedContractWithBearerAuthentication() {
.withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath(
"$.worker.stayExpiryDate", equalTo("2027-08-31")
))
.withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath(
"$.worker.createdAt", equalTo("2026-08-10T00:00:00Z")
))
.withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath(
"$.task.workflowId", equalTo("WF-CON-001")
))
Expand Down Expand Up @@ -114,6 +124,66 @@ void decodesTheWorkerGuideReviewContract() {
assertThat(response.caseSignals()).containsExactly("REVIEW_WORKER_GUIDE");
}

@Test
void logsOnlySafeValidationLocationsWhenTheAgentRejectsTheRequest() {
RenewalRunRequest request = request();
wireMock.stubFor(post(urlEqualTo(PATH))
.willReturn(aResponse()
.withStatus(422)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"detail":[
{
"type":"missing",
"loc":["body","phase"],
"msg":"Field required",
"input":{"passport_number":"M12345678"}
},
{
"type":"missing",
"loc":["body","analysisInput"],
"msg":"Field required"
}
]
}
""")));
Logger logger = (Logger) LoggerFactory.getLogger(RemoteRenewalRuntimeClient.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
assertThatThrownBy(() -> client().run(request, AiRuntimeCallContext.withoutTrace()))
.isInstanceOfSatisfying(AiRuntimeCallException.class, exception ->
assertThat(exception.failureCode())
.isEqualTo(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT));

assertThat(appender.list).hasSize(1);
assertThat(appender.list.get(0).getFormattedMessage())
.contains(
"request_id=" + request.requestId(),
"upstream_status=422",
"phase:missing",
"analysisInput:missing"
)
.doesNotContain("passport_number", "M12345678", "Field required");
} finally {
logger.detachAppender(appender);
appender.stop();
}
}

@Test
void treatsMissingRenewalEndpointAsRuntimeUnavailable() {
RenewalRunRequest request = request();
wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(404)));

assertThatThrownBy(() -> client().run(request, AiRuntimeCallContext.withoutTrace()))
.isInstanceOfSatisfying(AiRuntimeCallException.class, exception ->
assertThat(exception.failureCode())
.isEqualTo(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE));
}

private RemoteRenewalRuntimeClient client() {
return new RemoteRenewalRuntimeClient(
URI.create(wireMock.baseUrl() + PATH),
Expand Down
Loading