diff --git a/CHANGELOG.md b/CHANGELOG.md index b763500..a73fc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `WorkflowBlockedError` and `WorkflowApprovalRequiredError` exception classes - Builder-based option classes: `CheckGateOptions`, `StepCompletedOptions`, `CheckToolGateOptions`, `ToolCompletedOptions` - MCP interceptor types: `MCPInterceptorOptions`, `MCPToolRequest`, `MCPToolHandler`, `MCPToolInterceptor` +- `getCircuitBreakerStatus()` / `getCircuitBreakerStatusAsync()` — query active circuit breaker circuits and emergency stop state +- `getCircuitBreakerHistory(limit)` / `getCircuitBreakerHistoryAsync(limit)` — retrieve circuit breaker trip/reset audit trail +- `getCircuitBreakerConfig(tenantId)` / `getCircuitBreakerConfigAsync(tenantId)` — get effective circuit breaker config (global or tenant-specific) +- `updateCircuitBreakerConfig(config)` / `updateCircuitBreakerConfigAsync(config)` — update per-tenant circuit breaker thresholds --- diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index ed3bb69..23d6ca7 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -649,6 +649,178 @@ public CompletableFuture auditToolCallAsync(AuditToolCall return CompletableFuture.supplyAsync(() -> auditToolCall(request), asyncExecutor); } + // ======================================================================== + // Circuit Breaker Observability + // ======================================================================== + + /** + * Gets the current circuit breaker status, including all active (tripped) circuits. + * + *

Example usage: + *

{@code
+     * CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus();
+     * System.out.println("Active circuits: " + status.getCount());
+     * System.out.println("Emergency stop: " + status.isEmergencyStopActive());
+     * }
+ * + * @return the circuit breaker status + * @throws AxonFlowException if the request fails + */ + public CircuitBreakerStatusResponse getCircuitBreakerStatus() { + return retryExecutor.execute(() -> { + Request httpRequest = buildOrchestratorRequest("GET", "/api/v1/circuit-breaker/status", null); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), CircuitBreakerStatusResponse.class); + } + return objectMapper.treeToValue(node, CircuitBreakerStatusResponse.class); + } + }, "getCircuitBreakerStatus"); + } + + /** + * Asynchronously gets the current circuit breaker status. + * + * @return a future containing the circuit breaker status + */ + public CompletableFuture getCircuitBreakerStatusAsync() { + return CompletableFuture.supplyAsync(this::getCircuitBreakerStatus, asyncExecutor); + } + + /** + * Gets the circuit breaker history, including past trips and resets. + * + *

Example usage: + *

{@code
+     * CircuitBreakerHistoryResponse history = axonflow.getCircuitBreakerHistory(50);
+     * for (CircuitBreakerHistoryEntry entry : history.getHistory()) {
+     *     System.out.println(entry.getScope() + "/" + entry.getScopeId() + " - " + entry.getState());
+     * }
+     * }
+ * + * @param limit the maximum number of history entries to return + * @return the circuit breaker history + * @throws IllegalArgumentException if limit is less than 1 + * @throws AxonFlowException if the request fails + */ + public CircuitBreakerHistoryResponse getCircuitBreakerHistory(int limit) { + if (limit < 1) { + throw new IllegalArgumentException("limit must be at least 1"); + } + + return retryExecutor.execute(() -> { + String path = "/api/v1/circuit-breaker/history?limit=" + limit; + Request httpRequest = buildOrchestratorRequest("GET", path, null); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), CircuitBreakerHistoryResponse.class); + } + return objectMapper.treeToValue(node, CircuitBreakerHistoryResponse.class); + } + }, "getCircuitBreakerHistory"); + } + + /** + * Asynchronously gets the circuit breaker history. + * + * @param limit the maximum number of history entries to return + * @return a future containing the circuit breaker history + */ + public CompletableFuture getCircuitBreakerHistoryAsync(int limit) { + return CompletableFuture.supplyAsync(() -> getCircuitBreakerHistory(limit), asyncExecutor); + } + + /** + * Gets the circuit breaker configuration for a specific tenant. + * + *

Example usage: + *

{@code
+     * CircuitBreakerConfig config = axonflow.getCircuitBreakerConfig("tenant_123");
+     * System.out.println("Error threshold: " + config.getErrorThreshold());
+     * System.out.println("Auto recovery: " + config.isEnableAutoRecovery());
+     * }
+ * + * @param tenantId the tenant ID to get configuration for + * @return the circuit breaker configuration + * @throws NullPointerException if tenantId is null + * @throws IllegalArgumentException if tenantId is empty + * @throws AxonFlowException if the request fails + */ + public CircuitBreakerConfig getCircuitBreakerConfig(String tenantId) { + Objects.requireNonNull(tenantId, "tenantId cannot be null"); + if (tenantId.isEmpty()) { + throw new IllegalArgumentException("tenantId cannot be empty"); + } + + return retryExecutor.execute(() -> { + String path = "/api/v1/circuit-breaker/config?tenant_id=" + java.net.URLEncoder.encode(tenantId, java.nio.charset.StandardCharsets.UTF_8); + Request httpRequest = buildOrchestratorRequest("GET", path, null); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), CircuitBreakerConfig.class); + } + return objectMapper.treeToValue(node, CircuitBreakerConfig.class); + } + }, "getCircuitBreakerConfig"); + } + + /** + * Asynchronously gets the circuit breaker configuration for a specific tenant. + * + * @param tenantId the tenant ID to get configuration for + * @return a future containing the circuit breaker configuration + */ + public CompletableFuture getCircuitBreakerConfigAsync(String tenantId) { + return CompletableFuture.supplyAsync(() -> getCircuitBreakerConfig(tenantId), asyncExecutor); + } + + /** + * Updates the circuit breaker configuration for a tenant. + * + *

Example usage: + *

{@code
+     * CircuitBreakerConfig updated = axonflow.updateCircuitBreakerConfig(
+     *     CircuitBreakerConfigUpdate.builder()
+     *         .tenantId("tenant_123")
+     *         .errorThreshold(10)
+     *         .violationThreshold(5)
+     *         .enableAutoRecovery(true)
+     *         .build());
+     * }
+ * + * @param config the configuration update + * @return confirmation with tenant_id and message + * @throws NullPointerException if config is null + * @throws AxonFlowException if the request fails + */ + public CircuitBreakerConfigUpdateResponse updateCircuitBreakerConfig(CircuitBreakerConfigUpdate config) { + Objects.requireNonNull(config, "config cannot be null"); + + return retryExecutor.execute(() -> { + Request httpRequest = buildOrchestratorRequest("PUT", "/api/v1/circuit-breaker/config", config); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + if (node.has("data") && node.get("data").isObject()) { + return objectMapper.treeToValue(node.get("data"), CircuitBreakerConfigUpdateResponse.class); + } + return objectMapper.treeToValue(node, CircuitBreakerConfigUpdateResponse.class); + } + }, "updateCircuitBreakerConfig"); + } + + /** + * Asynchronously updates the circuit breaker configuration for a tenant. + * + * @param config the configuration update + * @return a future containing the update confirmation + */ + public CompletableFuture updateCircuitBreakerConfigAsync(CircuitBreakerConfigUpdate config) { + return CompletableFuture.supplyAsync(() -> updateCircuitBreakerConfig(config), asyncExecutor); + } + // ======================================================================== // Proxy Mode - Query Execution // ======================================================================== diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfig.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfig.java new file mode 100644 index 0000000..3061724 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfig.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Circuit breaker configuration for a tenant. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CircuitBreakerConfig { + + @JsonProperty("source") + private final String source; + + @JsonProperty("error_threshold") + private final int errorThreshold; + + @JsonProperty("violation_threshold") + private final int violationThreshold; + + @JsonProperty("window_seconds") + private final int windowSeconds; + + @JsonProperty("default_timeout_seconds") + private final int defaultTimeoutSeconds; + + @JsonProperty("max_timeout_seconds") + private final int maxTimeoutSeconds; + + @JsonProperty("enable_auto_recovery") + private final boolean enableAutoRecovery; + + @JsonProperty("tenant_id") + private final String tenantId; + + @JsonProperty("overrides") + private final Map overrides; + + public CircuitBreakerConfig( + @JsonProperty("source") String source, + @JsonProperty("error_threshold") int errorThreshold, + @JsonProperty("violation_threshold") int violationThreshold, + @JsonProperty("window_seconds") int windowSeconds, + @JsonProperty("default_timeout_seconds") int defaultTimeoutSeconds, + @JsonProperty("max_timeout_seconds") int maxTimeoutSeconds, + @JsonProperty("enable_auto_recovery") boolean enableAutoRecovery, + @JsonProperty("tenant_id") String tenantId, + @JsonProperty("overrides") Map overrides) { + this.source = source; + this.errorThreshold = errorThreshold; + this.violationThreshold = violationThreshold; + this.windowSeconds = windowSeconds; + this.defaultTimeoutSeconds = defaultTimeoutSeconds; + this.maxTimeoutSeconds = maxTimeoutSeconds; + this.enableAutoRecovery = enableAutoRecovery; + this.tenantId = tenantId; + this.overrides = overrides != null ? Map.copyOf(overrides) : null; + } + + public String getSource() { return source; } + public int getErrorThreshold() { return errorThreshold; } + public int getViolationThreshold() { return violationThreshold; } + public int getWindowSeconds() { return windowSeconds; } + public int getDefaultTimeoutSeconds() { return defaultTimeoutSeconds; } + public int getMaxTimeoutSeconds() { return maxTimeoutSeconds; } + public boolean isEnableAutoRecovery() { return enableAutoRecovery; } + public String getTenantId() { return tenantId; } + public Map getOverrides() { return overrides; } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdate.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdate.java new file mode 100644 index 0000000..f1cc53c --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdate.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Request to update circuit breaker configuration for a tenant. + * + *

Use the {@link Builder} to construct instances: + *

{@code
+ * CircuitBreakerConfigUpdate update = CircuitBreakerConfigUpdate.builder()
+ *     .tenantId("tenant_123")
+ *     .errorThreshold(10)
+ *     .enableAutoRecovery(true)
+ *     .build();
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CircuitBreakerConfigUpdate { + + @JsonProperty("tenant_id") + private final String tenantId; + + @JsonProperty("error_threshold") + private final Integer errorThreshold; + + @JsonProperty("violation_threshold") + private final Integer violationThreshold; + + @JsonProperty("window_seconds") + private final Integer windowSeconds; + + @JsonProperty("default_timeout_seconds") + private final Integer defaultTimeoutSeconds; + + @JsonProperty("max_timeout_seconds") + private final Integer maxTimeoutSeconds; + + @JsonProperty("enable_auto_recovery") + private final Boolean enableAutoRecovery; + + private CircuitBreakerConfigUpdate(Builder builder) { + this.tenantId = Objects.requireNonNull(builder.tenantId, "tenantId cannot be null"); + if (this.tenantId.isEmpty()) { + throw new IllegalArgumentException("tenantId cannot be empty"); + } + this.errorThreshold = builder.errorThreshold; + this.violationThreshold = builder.violationThreshold; + this.windowSeconds = builder.windowSeconds; + this.defaultTimeoutSeconds = builder.defaultTimeoutSeconds; + this.maxTimeoutSeconds = builder.maxTimeoutSeconds; + this.enableAutoRecovery = builder.enableAutoRecovery; + } + + /** + * Creates a new builder for CircuitBreakerConfigUpdate. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + public String getTenantId() { return tenantId; } + public Integer getErrorThreshold() { return errorThreshold; } + public Integer getViolationThreshold() { return violationThreshold; } + public Integer getWindowSeconds() { return windowSeconds; } + public Integer getDefaultTimeoutSeconds() { return defaultTimeoutSeconds; } + public Integer getMaxTimeoutSeconds() { return maxTimeoutSeconds; } + public Boolean getEnableAutoRecovery() { return enableAutoRecovery; } + + /** + * Builder for {@link CircuitBreakerConfigUpdate}. + */ + public static final class Builder { + private String tenantId; + private Integer errorThreshold; + private Integer violationThreshold; + private Integer windowSeconds; + private Integer defaultTimeoutSeconds; + private Integer maxTimeoutSeconds; + private Boolean enableAutoRecovery; + + public Builder tenantId(String tenantId) { this.tenantId = tenantId; return this; } + public Builder errorThreshold(int errorThreshold) { this.errorThreshold = errorThreshold; return this; } + public Builder violationThreshold(int violationThreshold) { this.violationThreshold = violationThreshold; return this; } + public Builder windowSeconds(int windowSeconds) { this.windowSeconds = windowSeconds; return this; } + public Builder defaultTimeoutSeconds(int defaultTimeoutSeconds) { this.defaultTimeoutSeconds = defaultTimeoutSeconds; return this; } + public Builder maxTimeoutSeconds(int maxTimeoutSeconds) { this.maxTimeoutSeconds = maxTimeoutSeconds; return this; } + public Builder enableAutoRecovery(boolean enableAutoRecovery) { this.enableAutoRecovery = enableAutoRecovery; return this; } + + /** + * Builds the CircuitBreakerConfigUpdate. + * + * @return the config update + * @throws NullPointerException if tenantId is null + * @throws IllegalArgumentException if tenantId is empty + */ + public CircuitBreakerConfigUpdate build() { + return new CircuitBreakerConfigUpdate(this); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdateResponse.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdateResponse.java new file mode 100644 index 0000000..7fc80ee --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerConfigUpdateResponse.java @@ -0,0 +1,35 @@ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response from updating circuit breaker configuration. + * + *

The backend returns a confirmation with tenant_id and message, + * not the full config object. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CircuitBreakerConfigUpdateResponse { + + @JsonProperty("tenant_id") + private final String tenantId; + + @JsonProperty("message") + private final String message; + + public CircuitBreakerConfigUpdateResponse( + @JsonProperty("tenant_id") String tenantId, + @JsonProperty("message") String message) { + this.tenantId = tenantId; + this.message = message; + } + + public String getTenantId() { return tenantId; } + public String getMessage() { return message; } + + @Override + public String toString() { + return "CircuitBreakerConfigUpdateResponse{tenantId='" + tenantId + "', message='" + message + "'}"; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryEntry.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryEntry.java new file mode 100644 index 0000000..79bb7fb --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryEntry.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single entry in circuit breaker history. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CircuitBreakerHistoryEntry { + + @JsonProperty("id") + private final String id; + + @JsonProperty("org_id") + private final String orgId; + + @JsonProperty("scope") + private final String scope; + + @JsonProperty("scope_id") + private final String scopeId; + + @JsonProperty("state") + private final String state; + + @JsonProperty("trip_reason") + private final String tripReason; + + @JsonProperty("tripped_by") + private final String trippedBy; + + @JsonProperty("tripped_at") + private final String trippedAt; + + @JsonProperty("expires_at") + private final String expiresAt; + + @JsonProperty("reset_by") + private final String resetBy; + + @JsonProperty("reset_at") + private final String resetAt; + + @JsonProperty("error_count") + private final int errorCount; + + @JsonProperty("violation_count") + private final int violationCount; + + public CircuitBreakerHistoryEntry( + @JsonProperty("id") String id, + @JsonProperty("org_id") String orgId, + @JsonProperty("scope") String scope, + @JsonProperty("scope_id") String scopeId, + @JsonProperty("state") String state, + @JsonProperty("trip_reason") String tripReason, + @JsonProperty("tripped_by") String trippedBy, + @JsonProperty("tripped_at") String trippedAt, + @JsonProperty("expires_at") String expiresAt, + @JsonProperty("reset_by") String resetBy, + @JsonProperty("reset_at") String resetAt, + @JsonProperty("error_count") int errorCount, + @JsonProperty("violation_count") int violationCount) { + this.id = id; + this.orgId = orgId; + this.scope = scope; + this.scopeId = scopeId; + this.state = state; + this.tripReason = tripReason; + this.trippedBy = trippedBy; + this.trippedAt = trippedAt; + this.expiresAt = expiresAt; + this.resetBy = resetBy; + this.resetAt = resetAt; + this.errorCount = errorCount; + this.violationCount = violationCount; + } + + public String getId() { return id; } + public String getOrgId() { return orgId; } + public String getScope() { return scope; } + public String getScopeId() { return scopeId; } + public String getState() { return state; } + public String getTripReason() { return tripReason; } + public String getTrippedBy() { return trippedBy; } + public String getTrippedAt() { return trippedAt; } + public String getExpiresAt() { return expiresAt; } + public String getResetBy() { return resetBy; } + public String getResetAt() { return resetAt; } + public int getErrorCount() { return errorCount; } + public int getViolationCount() { return violationCount; } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryResponse.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryResponse.java new file mode 100644 index 0000000..1cc6011 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerHistoryResponse.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Response from the circuit breaker history endpoint. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CircuitBreakerHistoryResponse { + + @JsonProperty("history") + private final List history; + + @JsonProperty("count") + private final int count; + + public CircuitBreakerHistoryResponse( + @JsonProperty("history") List history, + @JsonProperty("count") int count) { + this.history = history != null ? List.copyOf(history) : List.of(); + this.count = count; + } + + /** + * Returns the list of circuit breaker history entries. + * + * @return the history entries + */ + public List getHistory() { + return history; + } + + /** + * Returns the total number of history entries. + * + * @return the count + */ + public int getCount() { + return count; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerStatusResponse.java b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerStatusResponse.java new file mode 100644 index 0000000..8373a58 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/CircuitBreakerStatusResponse.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +/** + * Response from the circuit breaker status endpoint. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CircuitBreakerStatusResponse { + + @JsonProperty("active_circuits") + private final List> activeCircuits; + + @JsonProperty("count") + private final int count; + + @JsonProperty("emergency_stop_active") + private final boolean emergencyStopActive; + + public CircuitBreakerStatusResponse( + @JsonProperty("active_circuits") List> activeCircuits, + @JsonProperty("count") int count, + @JsonProperty("emergency_stop_active") boolean emergencyStopActive) { + this.activeCircuits = activeCircuits != null ? List.copyOf(activeCircuits) : List.of(); + this.count = count; + this.emergencyStopActive = emergencyStopActive; + } + + /** + * Returns the list of currently active (tripped) circuits. + * + * @return the active circuits + */ + public List> getActiveCircuits() { + return activeCircuits; + } + + /** + * Returns the number of active circuits. + * + * @return the count + */ + public int getCount() { + return count; + } + + /** + * Returns whether the emergency stop is currently active. + * + * @return true if emergency stop is active + */ + public boolean isEmergencyStopActive() { + return emergencyStopActive; + } +} diff --git a/src/test/java/com/getaxonflow/sdk/CircuitBreakerTest.java b/src/test/java/com/getaxonflow/sdk/CircuitBreakerTest.java new file mode 100644 index 0000000..b54dee7 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/CircuitBreakerTest.java @@ -0,0 +1,457 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk; + +import com.getaxonflow.sdk.exceptions.AxonFlowException; +import com.getaxonflow.sdk.types.*; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.concurrent.CompletableFuture; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.*; + +/** + * Tests for circuit breaker observability methods. + */ +@WireMockTest +@DisplayName("Circuit Breaker Observability") +class CircuitBreakerTest { + + private AxonFlow axonflow; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + axonflow = AxonFlow.create(AxonFlowConfig.builder() + .endpoint(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("test-client").clientSecret("test-secret") + .build()); + } + + // ======================================================================== + // getCircuitBreakerStatus + // ======================================================================== + + @Test + @DisplayName("should get circuit breaker status with active circuits") + void shouldGetCircuitBreakerStatus() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"active_circuits\":[{\"scope\":\"provider\",\"scope_id\":\"openai\",\"state\":\"open\",\"error_count\":15}],\"count\":1,\"emergency_stop_active\":false}}"))); + + CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus(); + + assertThat(status).isNotNull(); + assertThat(status.getCount()).isEqualTo(1); + assertThat(status.isEmergencyStopActive()).isFalse(); + assertThat(status.getActiveCircuits()).hasSize(1); + assertThat(status.getActiveCircuits().get(0).get("scope")).isEqualTo("provider"); + assertThat(status.getActiveCircuits().get(0).get("scope_id")).isEqualTo("openai"); + + verify(getRequestedFor(urlEqualTo("/api/v1/circuit-breaker/status"))); + } + + @Test + @DisplayName("should get circuit breaker status with no active circuits") + void shouldGetCircuitBreakerStatusEmpty() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"active_circuits\":[],\"count\":0,\"emergency_stop_active\":false}}"))); + + CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus(); + + assertThat(status).isNotNull(); + assertThat(status.getCount()).isEqualTo(0); + assertThat(status.getActiveCircuits()).isEmpty(); + } + + @Test + @DisplayName("should get circuit breaker status with emergency stop active") + void shouldGetCircuitBreakerStatusEmergencyStop() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"active_circuits\":[],\"count\":0,\"emergency_stop_active\":true}}"))); + + CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus(); + + assertThat(status.isEmergencyStopActive()).isTrue(); + } + + @Test + @DisplayName("getCircuitBreakerStatusAsync should return future") + void getCircuitBreakerStatusAsyncShouldReturnFuture() throws Exception { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"active_circuits\":[],\"count\":0,\"emergency_stop_active\":false}}"))); + + CompletableFuture future = axonflow.getCircuitBreakerStatusAsync(); + CircuitBreakerStatusResponse status = future.get(); + + assertThat(status).isNotNull(); + assertThat(status.getCount()).isEqualTo(0); + } + + @Test + @DisplayName("should handle server error on status") + void shouldHandleServerErrorOnStatus() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.getCircuitBreakerStatus()) + .isInstanceOf(AxonFlowException.class); + } + + // ======================================================================== + // getCircuitBreakerHistory + // ======================================================================== + + @Test + @DisplayName("should get circuit breaker history") + void shouldGetCircuitBreakerHistory() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/history?limit=10")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"history\":[{\"id\":\"cb_001\",\"org_id\":\"org_1\",\"scope\":\"provider\",\"scope_id\":\"openai\",\"state\":\"open\",\"trip_reason\":\"error_threshold\",\"tripped_by\":\"system\",\"tripped_at\":\"2026-03-16T10:00:00Z\",\"expires_at\":\"2026-03-16T10:05:00Z\",\"reset_by\":null,\"reset_at\":null,\"error_count\":15,\"violation_count\":0}],\"count\":1}}"))); + + CircuitBreakerHistoryResponse history = axonflow.getCircuitBreakerHistory(10); + + assertThat(history).isNotNull(); + assertThat(history.getCount()).isEqualTo(1); + assertThat(history.getHistory()).hasSize(1); + + CircuitBreakerHistoryEntry entry = history.getHistory().get(0); + assertThat(entry.getId()).isEqualTo("cb_001"); + assertThat(entry.getOrgId()).isEqualTo("org_1"); + assertThat(entry.getScope()).isEqualTo("provider"); + assertThat(entry.getScopeId()).isEqualTo("openai"); + assertThat(entry.getState()).isEqualTo("open"); + assertThat(entry.getTripReason()).isEqualTo("error_threshold"); + assertThat(entry.getTrippedBy()).isEqualTo("system"); + assertThat(entry.getTrippedAt()).isEqualTo("2026-03-16T10:00:00Z"); + assertThat(entry.getExpiresAt()).isEqualTo("2026-03-16T10:05:00Z"); + assertThat(entry.getResetBy()).isNull(); + assertThat(entry.getResetAt()).isNull(); + assertThat(entry.getErrorCount()).isEqualTo(15); + assertThat(entry.getViolationCount()).isEqualTo(0); + + verify(getRequestedFor(urlEqualTo("/api/v1/circuit-breaker/history?limit=10"))); + } + + @Test + @DisplayName("should get empty circuit breaker history") + void shouldGetEmptyCircuitBreakerHistory() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/history?limit=50")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"history\":[],\"count\":0}}"))); + + CircuitBreakerHistoryResponse history = axonflow.getCircuitBreakerHistory(50); + + assertThat(history).isNotNull(); + assertThat(history.getCount()).isEqualTo(0); + assertThat(history.getHistory()).isEmpty(); + } + + @Test + @DisplayName("should reject invalid limit") + void shouldRejectInvalidLimit() { + assertThatThrownBy(() -> axonflow.getCircuitBreakerHistory(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("limit must be at least 1"); + + assertThatThrownBy(() -> axonflow.getCircuitBreakerHistory(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("limit must be at least 1"); + } + + @Test + @DisplayName("getCircuitBreakerHistoryAsync should return future") + void getCircuitBreakerHistoryAsyncShouldReturnFuture() throws Exception { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/history?limit=25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"history\":[],\"count\":0}}"))); + + CompletableFuture future = axonflow.getCircuitBreakerHistoryAsync(25); + CircuitBreakerHistoryResponse history = future.get(); + + assertThat(history).isNotNull(); + assertThat(history.getCount()).isEqualTo(0); + } + + @Test + @DisplayName("should handle server error on history") + void shouldHandleServerErrorOnHistory() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/history?limit=10")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.getCircuitBreakerHistory(10)) + .isInstanceOf(AxonFlowException.class); + } + + // ======================================================================== + // getCircuitBreakerConfig + // ======================================================================== + + @Test + @DisplayName("should get circuit breaker config for tenant") + void shouldGetCircuitBreakerConfig() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/config?tenant_id=tenant_123")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"source\":\"tenant_override\",\"error_threshold\":10,\"violation_threshold\":5,\"window_seconds\":300,\"default_timeout_seconds\":60,\"max_timeout_seconds\":600,\"enable_auto_recovery\":true,\"tenant_id\":\"tenant_123\",\"overrides\":{\"provider_openai\":{\"error_threshold\":20}}}}"))); + + CircuitBreakerConfig config = axonflow.getCircuitBreakerConfig("tenant_123"); + + assertThat(config).isNotNull(); + assertThat(config.getSource()).isEqualTo("tenant_override"); + assertThat(config.getErrorThreshold()).isEqualTo(10); + assertThat(config.getViolationThreshold()).isEqualTo(5); + assertThat(config.getWindowSeconds()).isEqualTo(300); + assertThat(config.getDefaultTimeoutSeconds()).isEqualTo(60); + assertThat(config.getMaxTimeoutSeconds()).isEqualTo(600); + assertThat(config.isEnableAutoRecovery()).isTrue(); + assertThat(config.getTenantId()).isEqualTo("tenant_123"); + assertThat(config.getOverrides()).isNotNull(); + assertThat(config.getOverrides()).containsKey("provider_openai"); + + verify(getRequestedFor(urlEqualTo("/api/v1/circuit-breaker/config?tenant_id=tenant_123"))); + } + + @Test + @DisplayName("should get circuit breaker config with defaults") + void shouldGetCircuitBreakerConfigDefaults() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/config?tenant_id=new_tenant")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"source\":\"default\",\"error_threshold\":5,\"violation_threshold\":3,\"window_seconds\":60,\"default_timeout_seconds\":30,\"max_timeout_seconds\":300,\"enable_auto_recovery\":false,\"tenant_id\":\"new_tenant\"}}"))); + + CircuitBreakerConfig config = axonflow.getCircuitBreakerConfig("new_tenant"); + + assertThat(config).isNotNull(); + assertThat(config.getSource()).isEqualTo("default"); + assertThat(config.getOverrides()).isNull(); + } + + @Test + @DisplayName("should reject null tenantId for getConfig") + void shouldRejectNullTenantIdForGetConfig() { + assertThatThrownBy(() -> axonflow.getCircuitBreakerConfig(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tenantId cannot be null"); + } + + @Test + @DisplayName("should reject empty tenantId for getConfig") + void shouldRejectEmptyTenantIdForGetConfig() { + assertThatThrownBy(() -> axonflow.getCircuitBreakerConfig("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId cannot be empty"); + } + + @Test + @DisplayName("getCircuitBreakerConfigAsync should return future") + void getCircuitBreakerConfigAsyncShouldReturnFuture() throws Exception { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/config?tenant_id=async_tenant")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"source\":\"default\",\"error_threshold\":5,\"violation_threshold\":3,\"window_seconds\":60,\"default_timeout_seconds\":30,\"max_timeout_seconds\":300,\"enable_auto_recovery\":false,\"tenant_id\":\"async_tenant\"}}"))); + + CompletableFuture future = axonflow.getCircuitBreakerConfigAsync("async_tenant"); + CircuitBreakerConfig config = future.get(); + + assertThat(config).isNotNull(); + assertThat(config.getTenantId()).isEqualTo("async_tenant"); + } + + @Test + @DisplayName("should handle server error on getConfig") + void shouldHandleServerErrorOnGetConfig() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/config?tenant_id=bad_tenant")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.getCircuitBreakerConfig("bad_tenant")) + .isInstanceOf(AxonFlowException.class); + } + + // ======================================================================== + // updateCircuitBreakerConfig + // ======================================================================== + + @Test + @DisplayName("should update circuit breaker config") + void shouldUpdateCircuitBreakerConfig() { + stubFor(put(urlEqualTo("/api/v1/circuit-breaker/config")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"tenant_id\":\"tenant_123\",\"message\":\"Circuit breaker config updated for tenant\"}}"))); + + CircuitBreakerConfigUpdate update = CircuitBreakerConfigUpdate.builder() + .tenantId("tenant_123") + .errorThreshold(10) + .violationThreshold(5) + .windowSeconds(300) + .defaultTimeoutSeconds(60) + .maxTimeoutSeconds(600) + .enableAutoRecovery(true) + .build(); + + CircuitBreakerConfigUpdateResponse result = axonflow.updateCircuitBreakerConfig(update); + + assertThat(result).isNotNull(); + assertThat(result.getTenantId()).isEqualTo("tenant_123"); + assertThat(result.getMessage()).isNotEmpty(); + + verify(putRequestedFor(urlEqualTo("/api/v1/circuit-breaker/config")) + .withRequestBody(matchingJsonPath("$.tenant_id", equalTo("tenant_123"))) + .withRequestBody(matchingJsonPath("$.error_threshold", equalTo("10"))) + .withRequestBody(matchingJsonPath("$.violation_threshold", equalTo("5")))); + } + + @Test + @DisplayName("should update circuit breaker config with partial fields") + void shouldUpdateCircuitBreakerConfigPartial() { + stubFor(put(urlEqualTo("/api/v1/circuit-breaker/config")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"tenant_id\":\"tenant_456\",\"message\":\"Circuit breaker config updated for tenant\"}}"))); + + CircuitBreakerConfigUpdate update = CircuitBreakerConfigUpdate.builder() + .tenantId("tenant_456") + .errorThreshold(20) + .build(); + + CircuitBreakerConfigUpdateResponse result = axonflow.updateCircuitBreakerConfig(update); + + assertThat(result).isNotNull(); + assertThat(result.getTenantId()).isEqualTo("tenant_456"); + + verify(putRequestedFor(urlEqualTo("/api/v1/circuit-breaker/config")) + .withRequestBody(matchingJsonPath("$.tenant_id", equalTo("tenant_456"))) + .withRequestBody(matchingJsonPath("$.error_threshold", equalTo("20")))); + } + + @Test + @DisplayName("should reject null config for update") + void shouldRejectNullConfigForUpdate() { + assertThatThrownBy(() -> axonflow.updateCircuitBreakerConfig(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("config cannot be null"); + } + + @Test + @DisplayName("should reject null tenantId in config update builder") + void shouldRejectNullTenantIdInConfigUpdateBuilder() { + assertThatThrownBy(() -> CircuitBreakerConfigUpdate.builder().build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tenantId cannot be null"); + } + + @Test + @DisplayName("should reject empty tenantId in config update builder") + void shouldRejectEmptyTenantIdInConfigUpdateBuilder() { + assertThatThrownBy(() -> CircuitBreakerConfigUpdate.builder().tenantId("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId cannot be empty"); + } + + @Test + @DisplayName("updateCircuitBreakerConfigAsync should return future") + void updateCircuitBreakerConfigAsyncShouldReturnFuture() throws Exception { + stubFor(put(urlEqualTo("/api/v1/circuit-breaker/config")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"tenant_id\":\"tenant_async\",\"message\":\"Circuit breaker config updated for tenant\"}}"))); + + CircuitBreakerConfigUpdate update = CircuitBreakerConfigUpdate.builder() + .tenantId("tenant_async") + .errorThreshold(10) + .build(); + + CompletableFuture future = axonflow.updateCircuitBreakerConfigAsync(update); + CircuitBreakerConfigUpdateResponse result = future.get(); + + assertThat(result).isNotNull(); + assertThat(result.getTenantId()).isEqualTo("tenant_async"); + } + + @Test + @DisplayName("should handle server error on updateConfig") + void shouldHandleServerErrorOnUpdateConfig() { + stubFor(put(urlEqualTo("/api/v1/circuit-breaker/config")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + CircuitBreakerConfigUpdate update = CircuitBreakerConfigUpdate.builder() + .tenantId("failing_tenant") + .errorThreshold(10) + .build(); + + assertThatThrownBy(() -> axonflow.updateCircuitBreakerConfig(update)) + .isInstanceOf(AxonFlowException.class); + } + + // ======================================================================== + // Response without wrapper (fallback) + // ======================================================================== + + @Test + @DisplayName("should handle unwrapped response for status") + void shouldHandleUnwrappedResponseForStatus() { + stubFor(get(urlEqualTo("/api/v1/circuit-breaker/status")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"active_circuits\":[],\"count\":0,\"emergency_stop_active\":false}"))); + + CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus(); + + assertThat(status).isNotNull(); + assertThat(status.getCount()).isEqualTo(0); + } +}