From ffed007f369d472397a6be617b8996490e455bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 13 Feb 2026 01:35:18 +0100 Subject: [PATCH] feat: add configurable retry with backoff, implement service discovery stubs - Add retry with exponential backoff and jitter to RestServiceClientImpl - Fix Reactor Retry type erasure (jitter before filter in chain) - Implement Eureka service discovery client - Implement Consul service discovery client - Add fire-and-forget error handling for WebSocket close - Add unified exception hierarchy (kernel dependency) --- pom.xml | 7 + .../client/builder/RestClientBuilder.java | 44 ++++- .../ConsulServiceDiscoveryClient.java | 155 ++++++++++++++++-- .../EurekaServiceDiscoveryClient.java | 144 ++++++++++++++-- .../exception/ServiceClientException.java | 2 +- .../client/graphql/GraphQLClientHelper.java | 5 +- .../client/impl/RestServiceClientImpl.java | 68 +++++++- .../multipart/MultipartUploadHelper.java | 3 +- .../client/oauth2/OAuth2ClientHelper.java | 3 +- .../client/security/JwtValidator.java | 3 +- .../websocket/WebSocketClientHelper.java | 11 +- .../ServiceClientAutoConfiguration.java | 13 +- 12 files changed, 411 insertions(+), 47 deletions(-) diff --git a/pom.xml b/pom.xml index e380de3..aa229b2 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,13 @@ A unified client library that provides reactive communication patterns for REST, SOAP, and gRPC services with circuit breaker support, health checks, and comprehensive monitoring capabilities. + + + org.fireflyframework + fireflyframework-kernel + ${project.version} + + org.springframework.boot diff --git a/src/main/java/org/fireflyframework/client/builder/RestClientBuilder.java b/src/main/java/org/fireflyframework/client/builder/RestClientBuilder.java index 365a6ad..14cc099 100644 --- a/src/main/java/org/fireflyframework/client/builder/RestClientBuilder.java +++ b/src/main/java/org/fireflyframework/client/builder/RestClientBuilder.java @@ -64,6 +64,13 @@ public class RestClientBuilder { private WebClient webClient; private CircuitBreakerManager circuitBreakerManager; + // Retry configuration + private boolean retryEnabled = true; + private int retryMaxAttempts = 3; + private Duration retryInitialBackoff = Duration.ofMillis(500); + private Duration retryMaxBackoff = Duration.ofSeconds(10); + private boolean retryJitterEnabled = true; + /** * Creates a new REST client builder. * @@ -146,6 +153,36 @@ public RestClientBuilder circuitBreakerManager(CircuitBreakerManager circuitBrea return this; } + /** + * Configures retry behavior for the client. + * + * @param enabled whether retry is enabled + * @param maxAttempts maximum number of retry attempts + * @param initialBackoff initial wait duration before first retry + * @param maxBackoff maximum wait duration between retries + * @param jitterEnabled whether to add randomized jitter to backoff + * @return this builder + */ + public RestClientBuilder retry(boolean enabled, int maxAttempts, Duration initialBackoff, + Duration maxBackoff, boolean jitterEnabled) { + this.retryEnabled = enabled; + this.retryMaxAttempts = maxAttempts; + this.retryInitialBackoff = initialBackoff; + this.retryMaxBackoff = maxBackoff; + this.retryJitterEnabled = jitterEnabled; + return this; + } + + /** + * Disables retry for the client. + * + * @return this builder + */ + public RestClientBuilder noRetry() { + this.retryEnabled = false; + return this; + } + /** * Convenience method to set JSON content type headers. * @@ -179,7 +216,12 @@ public RestClient build() { maxConnections, defaultHeaders, webClient, - circuitBreakerManager + circuitBreakerManager, + retryEnabled, + retryMaxAttempts, + retryInitialBackoff, + retryMaxBackoff, + retryJitterEnabled ); } diff --git a/src/main/java/org/fireflyframework/client/discovery/ConsulServiceDiscoveryClient.java b/src/main/java/org/fireflyframework/client/discovery/ConsulServiceDiscoveryClient.java index 3991bb8..772703a 100644 --- a/src/main/java/org/fireflyframework/client/discovery/ConsulServiceDiscoveryClient.java +++ b/src/main/java/org/fireflyframework/client/discovery/ConsulServiceDiscoveryClient.java @@ -16,14 +16,22 @@ package org.fireflyframework.client.discovery; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Collections; +import java.util.List; +import java.util.Map; + /** * Consul-based service discovery client. - * - *

Integrates with HashiCorp Consul for service discovery. + * + *

Calls the Consul HTTP API to resolve healthy service instances, register, and deregister. * * @author Firefly Software Solutions Inc * @since 1.0.0 @@ -32,49 +40,166 @@ public class ConsulServiceDiscoveryClient implements ServiceDiscoveryClient { private final String consulUrl; + private final WebClient webClient; public ConsulServiceDiscoveryClient(String consulUrl) { - this.consulUrl = consulUrl; - log.info("Initialized Consul Service Discovery with URL: {}", consulUrl); + this.consulUrl = consulUrl.endsWith("/") ? consulUrl.substring(0, consulUrl.length() - 1) : consulUrl; + this.webClient = WebClient.builder() + .baseUrl(this.consulUrl) + .defaultHeader("Accept", "application/json") + .defaultHeader("Content-Type", "application/json") + .build(); + log.info("Initialized Consul Service Discovery with URL: {}", this.consulUrl); } @Override public Mono resolveEndpoint(String serviceName) { - // TODO: Implement actual Consul API call log.debug("Resolving endpoint for service: {} via Consul", serviceName); - return Mono.just("http://" + serviceName.toLowerCase()); + return getHealthyInstance(serviceName) + .map(ServiceInstance::getUri) + .switchIfEmpty(Mono.defer(() -> { + log.warn("No healthy instance found for service '{}' in Consul, falling back to name-based resolution", + serviceName); + return Mono.just("http://" + serviceName.toLowerCase()); + })); } @Override public Flux getInstances(String serviceName) { - // TODO: Implement actual Consul API call - return Flux.empty(); + log.debug("Getting instances for service: {} from Consul at {}", serviceName, consulUrl); + return webClient.get() + .uri("/v1/health/service/{serviceName}?passing=true", serviceName) + .retrieve() + .bodyToFlux(ConsulHealthServiceEntry.class) + .map(entry -> { + ConsulService svc = entry.getService(); + ConsulCheck[] checks = entry.getChecks(); + + HealthStatus status = HealthStatus.UP; + if (checks != null) { + for (ConsulCheck check : checks) { + if (!"passing".equalsIgnoreCase(check.getStatus())) { + status = HealthStatus.DOWN; + break; + } + } + } + + return new ServiceInstance( + svc.getId(), + serviceName, + svc.getAddress() != null ? svc.getAddress() : entry.getNode().getAddress(), + svc.getPort(), + false, + status, + svc.getMeta() != null ? svc.getMeta() : Map.of() + ); + }) + .onErrorResume(e -> { + log.error("Failed to get instances for service '{}' from Consul: {}", serviceName, e.getMessage()); + return Flux.empty(); + }); } @Override public Mono getHealthyInstance(String serviceName) { return getInstances(serviceName) - .filter(ServiceInstance::isHealthy) - .next(); + .filter(ServiceInstance::isHealthy) + .next(); } @Override public Mono register(ServiceInstance instance) { - // TODO: Implement Consul registration - log.info("Registering instance {} with Consul", instance.instanceId()); - return Mono.empty(); + log.info("Registering instance {} with Consul for service {}", instance.instanceId(), instance.serviceName()); + + ConsulServiceRegistration reg = new ConsulServiceRegistration(); + reg.setId(instance.instanceId()); + reg.setName(instance.serviceName()); + reg.setAddress(instance.host()); + reg.setPort(instance.port()); + reg.setMeta(instance.metadata()); + + return webClient.put() + .uri("/v1/agent/service/register") + .bodyValue(reg) + .retrieve() + .toBodilessEntity() + .doOnSuccess(r -> log.info("Successfully registered {} with Consul", instance.instanceId())) + .doOnError(e -> log.error("Failed to register {} with Consul: {}", instance.instanceId(), e.getMessage())) + .then(); } @Override public Mono deregister(String instanceId) { - // TODO: Implement Consul deregistration log.info("Deregistering instance {} from Consul", instanceId); - return Mono.empty(); + return webClient.put() + .uri("/v1/agent/service/deregister/{serviceId}", instanceId) + .retrieve() + .toBodilessEntity() + .doOnSuccess(r -> log.info("Successfully deregistered {} from Consul", instanceId)) + .doOnError(e -> log.error("Failed to deregister {} from Consul: {}", instanceId, e.getMessage())) + .then(); } @Override public Mono isServiceAvailable(String serviceName) { return getInstances(serviceName).hasElements(); } + + // --- Consul REST API DTOs --- + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class ConsulHealthServiceEntry { + @JsonProperty("Node") + private ConsulNode node; + @JsonProperty("Service") + private ConsulService service; + @JsonProperty("Checks") + private ConsulCheck[] checks; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class ConsulNode { + @JsonProperty("Address") + private String address; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class ConsulService { + @JsonProperty("ID") + private String id; + @JsonProperty("Service") + private String service; + @JsonProperty("Address") + private String address; + @JsonProperty("Port") + private int port; + @JsonProperty("Meta") + private Map meta; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class ConsulCheck { + @JsonProperty("Status") + private String status; + } + + @Data + static class ConsulServiceRegistration { + @JsonProperty("ID") + private String id; + @JsonProperty("Name") + private String name; + @JsonProperty("Address") + private String address; + @JsonProperty("Port") + private int port; + @JsonProperty("Meta") + private Map meta; + } } diff --git a/src/main/java/org/fireflyframework/client/discovery/EurekaServiceDiscoveryClient.java b/src/main/java/org/fireflyframework/client/discovery/EurekaServiceDiscoveryClient.java index f907b2b..aee9e51 100644 --- a/src/main/java/org/fireflyframework/client/discovery/EurekaServiceDiscoveryClient.java +++ b/src/main/java/org/fireflyframework/client/discovery/EurekaServiceDiscoveryClient.java @@ -16,16 +16,22 @@ package org.fireflyframework.client.discovery; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Collections; +import java.util.List; import java.util.Map; /** * Eureka-based service discovery client. - * - *

Integrates with Netflix Eureka for service discovery. + * + *

Calls the Eureka REST API to resolve service instances, register, and deregister. * * @author Firefly Software Solutions Inc * @since 1.0.0 @@ -34,49 +40,157 @@ public class EurekaServiceDiscoveryClient implements ServiceDiscoveryClient { private final String eurekaUrl; + private final WebClient webClient; public EurekaServiceDiscoveryClient(String eurekaUrl) { - this.eurekaUrl = eurekaUrl; - log.info("Initialized Eureka Service Discovery with URL: {}", eurekaUrl); + this.eurekaUrl = eurekaUrl.endsWith("/") ? eurekaUrl.substring(0, eurekaUrl.length() - 1) : eurekaUrl; + this.webClient = WebClient.builder() + .baseUrl(this.eurekaUrl) + .defaultHeader("Accept", "application/json") + .defaultHeader("Content-Type", "application/json") + .build(); + log.info("Initialized Eureka Service Discovery with URL: {}", this.eurekaUrl); } @Override public Mono resolveEndpoint(String serviceName) { - // TODO: Implement actual Eureka API call log.debug("Resolving endpoint for service: {} via Eureka", serviceName); - return Mono.just("http://" + serviceName.toLowerCase()); + return getHealthyInstance(serviceName) + .map(ServiceInstance::getUri) + .switchIfEmpty(Mono.defer(() -> { + log.warn("No healthy instance found for service '{}' in Eureka, falling back to name-based resolution", + serviceName); + return Mono.just("http://" + serviceName.toLowerCase()); + })); } @Override public Flux getInstances(String serviceName) { - // TODO: Implement actual Eureka API call to get instances - return Flux.empty(); + log.debug("Getting instances for service: {} from Eureka at {}", serviceName, eurekaUrl); + return webClient.get() + .uri("/eureka/apps/{appName}", serviceName.toUpperCase()) + .retrieve() + .bodyToMono(EurekaApplicationResponse.class) + .flatMapIterable(response -> { + if (response.getApplication() == null || response.getApplication().getInstance() == null) { + return Collections.emptyList(); + } + return response.getApplication().getInstance().stream() + .map(ei -> new ServiceInstance( + ei.getInstanceId(), + serviceName, + ei.getHostName(), + ei.getPort() != null ? ei.getPort().getValue() : 8080, + ei.getSecurePort() != null && ei.getSecurePort().isEnabled(), + "UP".equalsIgnoreCase(ei.getStatus()) ? HealthStatus.UP : HealthStatus.DOWN, + ei.getMetadata() != null ? ei.getMetadata() : Map.of() + )) + .toList(); + }) + .onErrorResume(e -> { + log.error("Failed to get instances for service '{}' from Eureka: {}", serviceName, e.getMessage()); + return Flux.empty(); + }); } @Override public Mono getHealthyInstance(String serviceName) { return getInstances(serviceName) - .filter(ServiceInstance::isHealthy) - .next(); + .filter(ServiceInstance::isHealthy) + .next(); } @Override public Mono register(ServiceInstance instance) { - // TODO: Implement Eureka registration - log.info("Registering instance {} with Eureka", instance.instanceId()); - return Mono.empty(); + log.info("Registering instance {} with Eureka for service {}", instance.instanceId(), instance.serviceName()); + + EurekaInstanceInfo info = new EurekaInstanceInfo(); + info.setInstanceId(instance.instanceId()); + info.setApp(instance.serviceName().toUpperCase()); + info.setHostName(instance.host()); + info.setIpAddr(instance.host()); + info.setStatus("UP"); + EurekaPort port = new EurekaPort(); + port.setValue(instance.port()); + port.setEnabled(true); + info.setPort(port); + + EurekaRegistrationRequest body = new EurekaRegistrationRequest(); + body.setInstance(info); + + return webClient.post() + .uri("/eureka/apps/{appName}", instance.serviceName().toUpperCase()) + .bodyValue(body) + .retrieve() + .toBodilessEntity() + .doOnSuccess(r -> log.info("Successfully registered {} with Eureka", instance.instanceId())) + .doOnError(e -> log.error("Failed to register {} with Eureka: {}", instance.instanceId(), e.getMessage())) + .then(); } @Override public Mono deregister(String instanceId) { - // TODO: Implement Eureka deregistration log.info("Deregistering instance {} from Eureka", instanceId); - return Mono.empty(); + + // Eureka instanceId format is typically: hostname:appName:port + // The DELETE endpoint is: /eureka/apps/{appName}/{instanceId} + String[] parts = instanceId.split(":"); + String appName = parts.length > 1 ? parts[1] : instanceId; + + return webClient.delete() + .uri("/eureka/apps/{appName}/{instanceId}", appName.toUpperCase(), instanceId) + .retrieve() + .toBodilessEntity() + .doOnSuccess(r -> log.info("Successfully deregistered {} from Eureka", instanceId)) + .doOnError(e -> log.error("Failed to deregister {} from Eureka: {}", instanceId, e.getMessage())) + .then(); } @Override public Mono isServiceAvailable(String serviceName) { return getInstances(serviceName).hasElements(); } + + // --- Eureka REST API DTOs --- + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class EurekaApplicationResponse { + private EurekaApplication application; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class EurekaApplication { + private String name; + private List instance; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class EurekaInstanceInfo { + private String instanceId; + private String app; + private String hostName; + private String ipAddr; + private String status; + private EurekaPort port; + private EurekaPort securePort; + private Map metadata; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + static class EurekaPort { + @JsonProperty("$") + private int value; + @JsonProperty("@enabled") + private boolean enabled; + } + + @Data + static class EurekaRegistrationRequest { + private EurekaInstanceInfo instance; + } } diff --git a/src/main/java/org/fireflyframework/client/exception/ServiceClientException.java b/src/main/java/org/fireflyframework/client/exception/ServiceClientException.java index 323d2a0..fe4d37b 100644 --- a/src/main/java/org/fireflyframework/client/exception/ServiceClientException.java +++ b/src/main/java/org/fireflyframework/client/exception/ServiceClientException.java @@ -39,7 +39,7 @@ * @since 1.0.0 */ @Getter -public class ServiceClientException extends RuntimeException { +public class ServiceClientException extends org.fireflyframework.kernel.exception.FireflyInfrastructureException { /** * Rich context information about the error. diff --git a/src/main/java/org/fireflyframework/client/graphql/GraphQLClientHelper.java b/src/main/java/org/fireflyframework/client/graphql/GraphQLClientHelper.java index c0c292e..630ac55 100644 --- a/src/main/java/org/fireflyframework/client/graphql/GraphQLClientHelper.java +++ b/src/main/java/org/fireflyframework/client/graphql/GraphQLClientHelper.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.exception.FireflyInfrastructureException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.ClientResponse; @@ -448,11 +449,11 @@ public static class GraphQLError { /** * GraphQL exception. */ - public static class GraphQLException extends RuntimeException { + public static class GraphQLException extends FireflyInfrastructureException { public GraphQLException(String message) { super(message); } - + public GraphQLException(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/org/fireflyframework/client/impl/RestServiceClientImpl.java b/src/main/java/org/fireflyframework/client/impl/RestServiceClientImpl.java index 3c7ffae..f9e2fdd 100644 --- a/src/main/java/org/fireflyframework/client/impl/RestServiceClientImpl.java +++ b/src/main/java/org/fireflyframework/client/impl/RestServiceClientImpl.java @@ -21,6 +21,7 @@ import org.fireflyframework.client.ClientType; import org.fireflyframework.client.RestClient; import org.fireflyframework.client.exception.HttpErrorMapper; +import org.fireflyframework.client.exception.RetryableError; import org.fireflyframework.client.exception.ServiceClientException; import org.fireflyframework.client.exception.ServiceSerializationException; import org.fireflyframework.client.exception.ErrorContext; @@ -29,6 +30,7 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import java.time.Duration; import java.time.Instant; @@ -69,6 +71,13 @@ public class RestServiceClientImpl implements RestClient { private final CircuitBreakerManager circuitBreakerManager; private final AtomicBoolean isShutdown = new AtomicBoolean(false); + // Retry configuration + private final boolean retryEnabled; + private final int retryMaxAttempts; + private final Duration retryInitialBackoff; + private final Duration retryMaxBackoff; + private final boolean retryJitterEnabled; + /** * Creates a new REST service client implementation. */ @@ -79,6 +88,25 @@ public RestServiceClientImpl(String serviceName, Map defaultHeaders, WebClient webClient, CircuitBreakerManager circuitBreakerManager) { + this(serviceName, baseUrl, timeout, maxConnections, defaultHeaders, webClient, + circuitBreakerManager, true, 3, Duration.ofMillis(500), Duration.ofSeconds(10), true); + } + + /** + * Creates a new REST service client implementation with retry configuration. + */ + public RestServiceClientImpl(String serviceName, + String baseUrl, + Duration timeout, + int maxConnections, + Map defaultHeaders, + WebClient webClient, + CircuitBreakerManager circuitBreakerManager, + boolean retryEnabled, + int retryMaxAttempts, + Duration retryInitialBackoff, + Duration retryMaxBackoff, + boolean retryJitterEnabled) { this.serviceName = serviceName; this.baseUrl = baseUrl; this.timeout = timeout; @@ -86,8 +114,14 @@ public RestServiceClientImpl(String serviceName, this.defaultHeaders = Map.copyOf(defaultHeaders); this.webClient = webClient != null ? webClient : createDefaultWebClient(); this.circuitBreakerManager = circuitBreakerManager; - - log.info("Initialized REST service client for '{}' with enhanced circuit breaker and base URL '{}'", serviceName, baseUrl); + this.retryEnabled = retryEnabled; + this.retryMaxAttempts = retryMaxAttempts; + this.retryInitialBackoff = retryInitialBackoff; + this.retryMaxBackoff = retryMaxBackoff; + this.retryJitterEnabled = retryJitterEnabled; + + log.info("Initialized REST service client for '{}' with base URL '{}', retry={} (maxAttempts={}, backoff={})", + serviceName, baseUrl, retryEnabled, retryMaxAttempts, retryInitialBackoff); } // ======================================== @@ -473,8 +507,34 @@ private Mono executeRequest(WebClient.RequestHeadersSpec requestSpec) { } }); - // Apply circuit breaker protection - return applyCircuitBreakerProtection(baseRequest); + // Apply retry (inside circuit breaker scope), then circuit breaker protection + Mono retriedRequest = applyRetry(baseRequest); + return applyCircuitBreakerProtection(retriedRequest); + } + + private Mono applyRetry(Mono operation) { + if (!retryEnabled || retryMaxAttempts <= 0) { + return operation; + } + + Retry retrySpec = Retry.backoff(retryMaxAttempts, retryInitialBackoff) + .maxBackoff(retryMaxBackoff) + .jitter(retryJitterEnabled ? 0.5 : 0.0) + .filter(throwable -> throwable instanceof RetryableError + && ((RetryableError) throwable).isRetryable()) + .doBeforeRetry(signal -> log.warn( + "Retrying request for service '{}' (attempt {}/{}): {}", + serviceName, signal.totalRetries() + 1, retryMaxAttempts, + signal.failure().getMessage())); + + return operation.retryWhen(retrySpec) + .onErrorMap(ex -> ex instanceof IllegalStateException && ex.getCause() != null + && ex.getMessage() != null && ex.getMessage().contains("Retries exhausted"), + ex -> { + log.error("All {} retry attempts exhausted for service '{}': {}", + retryMaxAttempts, serviceName, ex.getCause().getMessage()); + return ex.getCause(); + }); } private Mono applyCircuitBreakerProtection(Mono operation) { diff --git a/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java b/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java index 3ec6753..7890f3f 100644 --- a/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java +++ b/src/main/java/org/fireflyframework/client/multipart/MultipartUploadHelper.java @@ -1,6 +1,7 @@ package org.fireflyframework.client.multipart; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.exception.FireflyException; import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; @@ -941,7 +942,7 @@ public void validate(File file) throws UploadValidationException { /** * Exception thrown when file validation fails. */ - public static class UploadValidationException extends RuntimeException { + public static class UploadValidationException extends FireflyException { public UploadValidationException(String message) { super(message); } diff --git a/src/main/java/org/fireflyframework/client/oauth2/OAuth2ClientHelper.java b/src/main/java/org/fireflyframework/client/oauth2/OAuth2ClientHelper.java index 549960f..dc26ea2 100644 --- a/src/main/java/org/fireflyframework/client/oauth2/OAuth2ClientHelper.java +++ b/src/main/java/org/fireflyframework/client/oauth2/OAuth2ClientHelper.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.exception.FireflySecurityException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -574,7 +575,7 @@ public String toString() { /** * OAuth2 exception. */ - public static class OAuth2Exception extends RuntimeException { + public static class OAuth2Exception extends FireflySecurityException { public OAuth2Exception(String message) { super(message); } diff --git a/src/main/java/org/fireflyframework/client/security/JwtValidator.java b/src/main/java/org/fireflyframework/client/security/JwtValidator.java index f4db45a..4cfe942 100644 --- a/src/main/java/org/fireflyframework/client/security/JwtValidator.java +++ b/src/main/java/org/fireflyframework/client/security/JwtValidator.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.exception.FireflySecurityException; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -296,7 +297,7 @@ public T getClaim(String name, Class type) { /** * JWT validation exception. */ - public static class JwtValidationException extends Exception { + public static class JwtValidationException extends FireflySecurityException { public JwtValidationException(String message) { super(message); } diff --git a/src/main/java/org/fireflyframework/client/websocket/WebSocketClientHelper.java b/src/main/java/org/fireflyframework/client/websocket/WebSocketClientHelper.java index bdefc30..32f0b16 100644 --- a/src/main/java/org/fireflyframework/client/websocket/WebSocketClientHelper.java +++ b/src/main/java/org/fireflyframework/client/websocket/WebSocketClientHelper.java @@ -1,6 +1,7 @@ package org.fireflyframework.client.websocket; import lombok.extern.slf4j.Slf4j; +import org.fireflyframework.kernel.exception.FireflyInfrastructureException; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; import org.springframework.web.reactive.socket.WebSocketHandler; @@ -661,7 +662,9 @@ public void disconnect() { WebSocketSession session = currentSession.getAndSet(null); if (session != null) { try { - session.close().subscribe(); + session.close() + .doOnError(err -> log.warn("Error during WebSocket session close for {}: {}", url, err.getMessage())) + .subscribe(); log.info("Disconnected WebSocket for {}", url); } catch (Exception e) { log.warn("Error closing WebSocket session for {}: {}", url, e.getMessage()); @@ -724,7 +727,7 @@ public static void clearPool() { /** * Custom exception for WebSocket reconnection failures. */ - public static class WebSocketReconnectionException extends RuntimeException { + public static class WebSocketReconnectionException extends FireflyInfrastructureException { public WebSocketReconnectionException(String message) { super(message); } @@ -733,7 +736,7 @@ public WebSocketReconnectionException(String message) { /** * Custom exception for WebSocket queue full. */ - public static class WebSocketQueueFullException extends RuntimeException { + public static class WebSocketQueueFullException extends FireflyInfrastructureException { public WebSocketQueueFullException(String message) { super(message); } @@ -742,7 +745,7 @@ public WebSocketQueueFullException(String message) { /** * Custom exception for WebSocket not connected. */ - public static class WebSocketNotConnectedException extends RuntimeException { + public static class WebSocketNotConnectedException extends FireflyInfrastructureException { public WebSocketNotConnectedException(String message) { super(message); } diff --git a/src/main/java/org/fireflyframework/config/ServiceClientAutoConfiguration.java b/src/main/java/org/fireflyframework/config/ServiceClientAutoConfiguration.java index 6bcf958..1643659 100644 --- a/src/main/java/org/fireflyframework/config/ServiceClientAutoConfiguration.java +++ b/src/main/java/org/fireflyframework/config/ServiceClientAutoConfiguration.java @@ -160,9 +160,18 @@ public CircuitBreakerManager circuitBreakerManager(CircuitBreakerConfig config) @Bean @ConditionalOnMissingBean public RestClientBuilder restClientBuilder(CircuitBreakerManager circuitBreakerManager) { - log.info("Configuring default REST client builder with enhanced circuit breaker"); + log.info("Configuring default REST client builder with enhanced circuit breaker and retry"); + + var retryProps = properties.getRetry(); return new RestClientBuilder("default") - .circuitBreakerManager(circuitBreakerManager); + .circuitBreakerManager(circuitBreakerManager) + .retry( + retryProps.isEnabled(), + retryProps.getMaxAttempts(), + retryProps.getWaitDuration(), + retryProps.getMaxWaitDuration(), + retryProps.isJitterEnabled() + ); } /**