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
1 change: 1 addition & 0 deletions blockchain-custody/blockchain-custody/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ dependencies {
// Resilience4j
implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion")
implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion")
implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Confirm this new dependency is intentionally required in this module.

resilience4j-retry is newly added in main scope. Please confirm this is required (vs transitive availability) and remains aligned with your version management approach to avoid dependency drift.

As per coding guidelines, **/*.gradle.kts: “Flag any newly added dependencies and ask if they are necessary” and “Check for version catalog alignment (all versions should come from libs.versions.toml if present)”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blockchain-custody/blockchain-custody/build.gradle.kts` at line 103, The new
dependency
implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion")
appears added without confirmation—verify whether resilience4j-retry is actually
required by this module (vs available transitively) and either remove it if
unnecessary or keep it but align it to the project version-management approach:
if you use a version catalog (libs.versions.toml) reference the catalog entry
(e.g., libs.resilience4j.retry) instead of a raw $resilience4jVersion, or if
project-wide variables are used ensure $resilience4jVersion is defined
centrally; update the build.gradle.kts dependency declaration accordingly and
add a brief justification in the PR comment.


// Dev custody adapter — EVM transaction signing
implementation("org.web3j:core:$web3jVersion") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ app:
fallback-adapters:
enabled: true

resilience4j:
retry:
instances:
fireblocks:
wait-duration: 10ms
evmRpc:
wait-duration: 10ms
solanaRpc:
wait-duration: 10ms

logging:
level:
com.stablecoin.payments: DEBUG
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.stablecoin.payments.custody.domain.port.ChainRpcProvider;
import com.stablecoin.payments.custody.domain.port.TransactionReceipt;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand Down Expand Up @@ -61,6 +62,7 @@ public EvmRpcAdapter(EvmChainProperties properties) {
}

@Override
@Retry(name = "evmRpc", fallbackMethod = "getTransactionReceiptFallback")
@CircuitBreaker(name = "evmRpc", fallbackMethod = "getTransactionReceiptFallback")
public TransactionReceipt getTransactionReceipt(ChainId chainId, String txHash) {
Comment on lines +65 to 67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same nested call proxy bypass as Solana adapter.

getTransactionReceipt internally calls getLatestBlockNumber at line 83. This bypasses the retry aspect on the inner method since it's a self-invocation within the same proxy. The outer method's retry will re-execute the entire flow if it fails.

Also applies to: 83-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/provider/evm/EvmRpcAdapter.java`
around lines 65 - 67, getTransactionReceipt currently calls getLatestBlockNumber
directly which bypasses the Resilience4j proxy on that inner method
(self-invocation). Make getLatestBlockNumber be invoked through the Spring proxy
so its `@Retry/`@CircuitBreaker take effect: either move getLatestBlockNumber into
a separate `@Component` (e.g., EvmRpcClient) with its annotations and inject that
component into EvmRpcAdapter, or keep it in this class but make it public and
inject the EvmRpcAdapter bean (self) and call self.getLatestBlockNumber(...)
instead of calling the method directly; update references accordingly and keep
the existing fallback getTransactionReceiptFallback unchanged.

log.info("[EVM-RPC] Getting transaction receipt chain={} txHash={}", chainId.value(), txHash);
Expand Down Expand Up @@ -88,6 +90,7 @@ public TransactionReceipt getTransactionReceipt(ChainId chainId, String txHash)
}

@Override
@Retry(name = "evmRpc", fallbackMethod = "getLatestBlockNumberFallback")
@CircuitBreaker(name = "evmRpc", fallbackMethod = "getLatestBlockNumberFallback")
public long getLatestBlockNumber(ChainId chainId) {
log.info("[EVM-RPC] Getting latest block number chain={}", chainId.value());
Expand All @@ -100,6 +103,7 @@ public long getLatestBlockNumber(ChainId chainId) {
}

@Override
@Retry(name = "evmRpc", fallbackMethod = "getTokenBalanceFallback")
@CircuitBreaker(name = "evmRpc", fallbackMethod = "getTokenBalanceFallback")
public BigDecimal getTokenBalance(ChainId chainId, String address, String tokenContract) {
log.info("[EVM-RPC] Getting token balance chain={} address={} contract={}",
Expand Down Expand Up @@ -177,23 +181,23 @@ static String encodeBalanceOfCall(String address) {

@SuppressWarnings("unused")
private TransactionReceipt getTransactionReceiptFallback(ChainId chainId, String txHash, Exception ex) {
log.error("[EVM-RPC] Circuit breaker open — getTransactionReceipt failed chain={} txHash={}",
chainId.value(), txHash, ex);
log.error("[EVM-RPC] Resilience fallback — getTransactionReceipt failed chain={} txHash={} due to {}",
chainId.value(), txHash, ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("EVM RPC unavailable for getTransactionReceipt", ex);
}

@SuppressWarnings("unused")
private long getLatestBlockNumberFallback(ChainId chainId, Exception ex) {
log.error("[EVM-RPC] Circuit breaker open — getLatestBlockNumber failed chain={}",
chainId.value(), ex);
log.error("[EVM-RPC] Resilience fallback — getLatestBlockNumber failed chain={} due to {}",
chainId.value(), ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("EVM RPC unavailable for getLatestBlockNumber", ex);
}

@SuppressWarnings("unused")
private BigDecimal getTokenBalanceFallback(ChainId chainId, String address, String tokenContract,
Exception ex) {
log.error("[EVM-RPC] Circuit breaker open — getTokenBalance failed chain={} address={}",
chainId.value(), address, ex);
log.error("[EVM-RPC] Resilience fallback — getTokenBalance failed chain={} address={} due to {}",
chainId.value(), address, ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("EVM RPC unavailable for getTokenBalance", ex);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.stablecoin.payments.custody.domain.port.SignResult;
import com.stablecoin.payments.custody.domain.port.TransactionStatus;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand Down Expand Up @@ -68,6 +69,7 @@ public FireblocksCustodyAdapter(FireblocksProperties properties) {
}

@Override
@Retry(name = "fireblocks", fallbackMethod = "signAndSubmitFallback")
@CircuitBreaker(name = "fireblocks", fallbackMethod = "signAndSubmitFallback")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
public SignResult signAndSubmit(SignRequest request) {
Comment on lines +72 to 74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Resilience4j Spring annotations, when both @Retryand@CIRCUITBREAKER define fallbackMethod, does a broad CircuitBreaker fallback(Exception) prevent retry attempts? Please cite official docs/issues.

💡 Result:

Yes. With Resilience4j Spring annotations, the aspects are nested by default as:

Retry ( CircuitBreaker ( … ( Function ) ) ) — i.e., Retry wraps CircuitBreaker. If the CircuitBreaker executes a fallbackMethod that matches the thrown exception (e.g., fallback(..., Exception) / RuntimeException), it handles the failure and returns a normal value, so no exception escapes outward to the Retry aspect, and therefore Retry won’t perform any retry attempts. This exact “retry never called when CircuitBreaker specifies a fallback” behavior is documented in Resilience4j issue #558. [1][2]

Why a broad fallback(Exception) is especially “blocking”:

  • Resilience4j chooses the fallback based on the closest matching exception parameter; a broad Exception/RuntimeException fallback will match many failures, so it will be used and will swallow the exception before Retry can see it. [1]

How to avoid suppressing retries (per the same issue/docs):

  • Don’t use a broad CircuitBreaker fallback; instead, make the CircuitBreaker fallback only handle the “open circuit” case (e.g., CallNotPermittedException), so other exceptions propagate to Retry. [2]
  • Or change aspect order via properties so CircuitBreaker runs after Retry (so Retry gets a chance to retry before CB fallback handles), using retryAspectOrder / circuitBreakerAspectOrder. [1]

Sources (official):

  • Resilience4j “Getting Started” docs: fallback matching rules + default aspect order + aspect-order properties. [1]
  • Resilience4j GitHub issue #558: confirms CircuitBreaker fallback short-circuits Retry; suggests using CallNotPermittedException-typed CB fallback to avoid it. [2]

🏁 Script executed:

# Get the file size and relevant sections
wc -l blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapter.java

Repository: Puneethkumarck/stablebridge-platform

Length of output: 232


🏁 Script executed:

# Read the critical sections: 70-150 to see method signatures and fallback implementation
sed -n '70,150p' blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapter.java

Repository: Puneethkumarck/stablebridge-platform

Length of output: 3816


Replace broad Exception fallback to prevent CircuitBreaker from short-circuiting Retry logic.

Both @Retry and @CircuitBreaker reference the same fallback method accepting Exception, causing the CircuitBreaker aspect (which wraps Retry) to intercept and handle exceptions before Retry can attempt retries. Even though the fallback re-throws, the retry mechanism never executes. Define separate fallback strategies: CircuitBreaker fallback should handle only CallNotPermittedException, allowing other exceptions to propagate to Retry. Alternatively, configure aspect order via retryAspectOrder / circuitBreakerAspectOrder properties so Retry executes first.

Applies to:

  • signAndSubmitFallback (lines 72–74, 136–140)
  • getTransactionStatusFallback (lines 114–115, 142–147)

Note: Fireblocks requests include transfer ID as an idempotency key, but retry logic should still be explicitly ordered to handle transient failures correctly without silent suppression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapter.java`
around lines 72 - 74, The CircuitBreaker is short-circuiting Retry because both
`@CircuitBreaker` and `@Retry` point to the same fallback that accepts broad
Exception; update the fallbacks so the CircuitBreaker fallback only handles
CallNotPermittedException (e.g., create
signAndSubmitCircuitBreakerFallback(CallNotPermittedException ex, SignRequest
request) and
getTransactionStatusCircuitBreakerFallback(CallNotPermittedException ex, String
txId)) while keeping the Retry fallback to accept the broader Exception
(signAndSubmitFallback(Exception ex, SignRequest request) and
getTransactionStatusFallback(Exception ex, String txId)), or alternatively set
explicit aspect order via retryAspectOrder/circuitBreakerAspectOrder so Retry
runs before CircuitBreaker; ensure the new fallback signatures match the methods
signAndSubmit and getTransactionStatus to let Retry perform retries for
transient errors.

log.info("[FIREBLOCKS] Signing and submitting transfer transferId={} chain={} to={}",
Expand Down Expand Up @@ -109,6 +111,7 @@ public SignResult signAndSubmit(SignRequest request) {
}

@Override
@Retry(name = "fireblocks", fallbackMethod = "getTransactionStatusFallback")
@CircuitBreaker(name = "fireblocks", fallbackMethod = "getTransactionStatusFallback")
public TransactionStatus getTransactionStatus(String txId) {
log.info("[FIREBLOCKS] Getting transaction status txId={}", txId);
Expand All @@ -131,14 +134,15 @@ public TransactionStatus getTransactionStatus(String txId) {

@SuppressWarnings("unused")
private SignResult signAndSubmitFallback(SignRequest request, Exception ex) {
log.error("[FIREBLOCKS] Circuit breaker open — signAndSubmit failed transferId={}",
request.transferId(), ex);
log.error("[FIREBLOCKS] Resilience fallback — signAndSubmit failed transferId={} due to {}",
request.transferId(), ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("Fireblocks custody unavailable", ex);
}

@SuppressWarnings("unused")
private TransactionStatus getTransactionStatusFallback(String txId, Exception ex) {
log.error("[FIREBLOCKS] Circuit breaker open — getTransactionStatus failed txId={}", txId, ex);
log.error("[FIREBLOCKS] Resilience fallback — getTransactionStatus failed txId={} due to {}",
txId, ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("Fireblocks custody unavailable", ex);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.stablecoin.payments.custody.domain.port.ChainRpcProvider;
import com.stablecoin.payments.custody.domain.port.TransactionReceipt;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand Down Expand Up @@ -55,6 +56,7 @@ public SolanaRpcAdapter(SolanaChainProperties properties) {
}

@Override
@Retry(name = "solanaRpc", fallbackMethod = "getTransactionReceiptFallback")
@CircuitBreaker(name = "solanaRpc", fallbackMethod = "getTransactionReceiptFallback")
public TransactionReceipt getTransactionReceipt(ChainId chainId, String txHash) {
Comment on lines +59 to 61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Nested RPC call bypasses retry for inner method.

getTransactionReceipt at line 81 invokes getLatestBlockNumber directly. Since both methods are on the same bean, this internal call bypasses the proxy—@Retry on getLatestBlockNumber won't apply. If getSlot fails, no retry occurs for that inner call.

If independent retry per RPC call is desired, extract to a separate bean or accept the current behavior where the outer method's retry will re-execute the entire flow including the inner call.

Also applies to: 81-81

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/provider/solana/SolanaRpcAdapter.java`
around lines 59 - 61, The getTransactionReceipt method in SolanaRpcAdapter calls
getLatestBlockNumber/getSlot directly which bypasses Resilience4j proxies so
`@Retry` on getLatestBlockNumber/getSlot won't run; to fix, move the RPC helper
methods (getLatestBlockNumber and getSlot) into a separate Spring bean (e.g.,
SolanaRpcClient) or obtain a proxied reference to this bean and call them
through that proxy from getTransactionReceipt, ensuring the calls go through the
proxy so their `@Retry/`@CircuitBreaker annotations are applied; update references
in SolanaRpcAdapter to call the new bean methods instead of the private/internal
methods.

log.info("[SOLANA-RPC] Getting transaction chain={} signature={}", chainId.value(), txHash);
Expand Down Expand Up @@ -86,6 +88,7 @@ public TransactionReceipt getTransactionReceipt(ChainId chainId, String txHash)
}

@Override
@Retry(name = "solanaRpc", fallbackMethod = "getLatestBlockNumberFallback")
@CircuitBreaker(name = "solanaRpc", fallbackMethod = "getLatestBlockNumberFallback")
public long getLatestBlockNumber(ChainId chainId) {
log.info("[SOLANA-RPC] Getting current slot chain={}", chainId.value());
Expand All @@ -99,6 +102,7 @@ public long getLatestBlockNumber(ChainId chainId) {
}

@Override
@Retry(name = "solanaRpc", fallbackMethod = "getTokenBalanceFallback")
@CircuitBreaker(name = "solanaRpc", fallbackMethod = "getTokenBalanceFallback")
public BigDecimal getTokenBalance(ChainId chainId, String address, String tokenContract) {
log.info("[SOLANA-RPC] Getting SPL token balance chain={} owner={} mint={}",
Expand Down Expand Up @@ -167,23 +171,23 @@ JsonNode callJsonRpc(String method, Object... params) {

@SuppressWarnings("unused")
private TransactionReceipt getTransactionReceiptFallback(ChainId chainId, String txHash, Exception ex) {
log.error("[SOLANA-RPC] Circuit breaker open - getTransactionReceipt failed chain={} signature={}",
chainId.value(), txHash, ex);
log.error("[SOLANA-RPC] Resilience fallback — getTransactionReceipt failed chain={} signature={} due to {}",
chainId.value(), txHash, ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("Solana RPC unavailable for getTransactionReceipt", ex);
}

@SuppressWarnings("unused")
private long getLatestBlockNumberFallback(ChainId chainId, Exception ex) {
log.error("[SOLANA-RPC] Circuit breaker open - getLatestBlockNumber failed chain={}",
chainId.value(), ex);
log.error("[SOLANA-RPC] Resilience fallback — getLatestBlockNumber failed chain={} due to {}",
chainId.value(), ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("Solana RPC unavailable for getSlot", ex);
}

@SuppressWarnings("unused")
private BigDecimal getTokenBalanceFallback(ChainId chainId, String address, String tokenContract,
Exception ex) {
log.error("[SOLANA-RPC] Circuit breaker open - getTokenBalance failed chain={} owner={}",
chainId.value(), address, ex);
log.error("[SOLANA-RPC] Resilience fallback — getTokenBalance failed chain={} owner={} due to {}",
chainId.value(), address, ex.getClass().getSimpleName(), ex);
throw new IllegalStateException("Solana RPC unavailable for getTokenBalance", ex);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ app:
balance:
low-alert-threshold-usd: 50000

resilience4j:
circuitbreaker:
circuit-breaker-aspect-order: 1
retry:
retry-aspect-order: 3
instances:
fireblocks:
max-attempts: 3
wait-duration: 1s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.net.http.HttpTimeoutException
- java.net.http.HttpConnectTimeoutException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
evmRpc:
max-attempts: 5
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.net.http.HttpTimeoutException
- java.net.http.HttpConnectTimeoutException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
solanaRpc:
max-attempts: 5
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.net.http.HttpTimeoutException
- java.net.http.HttpConnectTimeoutException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException
- io.github.resilience4j.circuitbreaker.CallNotPermittedException

logging:
level:
com.stablecoin.payments: INFO
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.stablecoin.payments.custody.infrastructure.provider.evm;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.stubbing.Scenario;
import com.stablecoin.payments.custody.domain.model.ChainId;
import com.stablecoin.payments.custody.domain.port.ChainRpcProvider;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration;
import io.github.resilience4j.springboot3.retry.autoconfigure.RetryAutoConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

import java.util.Map;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
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;

@SpringBootTest(classes = EvmRpcAdapterRetryTest.TestConfig.class)
@DisplayName("EvmRpcAdapter retry behavior")
class EvmRpcAdapterRetryTest {

private static WireMockServer wireMock;
private static final ChainId BASE_CHAIN = new ChainId("base");

@Autowired
private ChainRpcProvider chainRpcProvider;

@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;

@BeforeAll
static void startWireMock() {
wireMock = new WireMockServer(wireMockConfig().dynamicPort());
wireMock.start();
}

@AfterAll
static void stopWireMock() {
wireMock.stop();
}

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("app.custody.evm.enabled", () -> "true");
registry.add("resilience4j.retry.instances.evmRpc.max-attempts", () -> "3");
registry.add("resilience4j.retry.instances.evmRpc.wait-duration", () -> "10ms");
registry.add("resilience4j.retry.instances.evmRpc.retry-exceptions[0]",
() -> "org.springframework.web.client.HttpServerErrorException");
registry.add("resilience4j.retry.instances.evmRpc.ignore-exceptions[0]",
() -> "org.springframework.web.client.HttpClientErrorException");
registry.add("resilience4j.retry.retry-aspect-order", () -> "3");
registry.add("resilience4j.circuitbreaker.circuit-breaker-aspect-order", () -> "1");
}

@BeforeEach
void setUp() {
wireMock.resetAll();
circuitBreakerRegistry.getAllCircuitBreakers()
.forEach(cb -> cb.transitionToClosedState());
}

@Test
@DisplayName("should retry on transient 503 failure then succeed")
void shouldRetryOnTransientFailureThenSucceed() {
wireMock.stubFor(post(urlEqualTo("/"))
.inScenario("retry-then-success")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("second-attempt"));

wireMock.stubFor(post(urlEqualTo("/"))
.inScenario("retry-then-success")
.whenScenarioStateIs("second-attempt")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"jsonrpc":"2.0","id":1,"result":"0x1a4"}
""")));

var result = chainRpcProvider.getLatestBlockNumber(BASE_CHAIN);

assertThat(result).isEqualTo(420L);
}

@Test
@DisplayName("should not retry on 400 client error")
void shouldNotRetryOnClientError() {
wireMock.stubFor(post(urlEqualTo("/"))
.willReturn(aResponse().withStatus(400)));

assertThatThrownBy(() -> chainRpcProvider.getLatestBlockNumber(BASE_CHAIN))
.isInstanceOf(IllegalStateException.class)
.hasMessage("EVM RPC unavailable for getLatestBlockNumber");

wireMock.verify(1, postRequestedFor(urlEqualTo("/")));
}

@Test
@DisplayName("should exhaust retries on persistent 503 and invoke fallback")
void shouldExhaustRetriesAndInvokeFallback() {
wireMock.stubFor(post(urlEqualTo("/"))
.willReturn(aResponse().withStatus(503)));

assertThatThrownBy(() -> chainRpcProvider.getLatestBlockNumber(BASE_CHAIN))
.isInstanceOf(IllegalStateException.class)
.hasMessage("EVM RPC unavailable for getLatestBlockNumber");

wireMock.verify(3, postRequestedFor(urlEqualTo("/")));
}
Comment on lines +78 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Same coverage suggestion: add tests for getTransactionReceipt and getTokenBalance.

Consistent with the Solana test feedback—all three public methods have @Retry annotations but only getLatestBlockNumber is exercised. The EVM adapter's hex parsing paths in getTransactionReceipt and getTokenBalance would benefit from retry scenario coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/evm/EvmRpcAdapterRetryTest.java`
around lines 78 - 126, Add parallel retry tests for getTransactionReceipt and
getTokenBalance mirroring the existing getLatestBlockNumber scenarios: for each
method create (1) a transient 503 then 200 success case that verifies correct
parsing of the hex response, (2) a 400 client error case that asserts
IllegalStateException with message "EVM RPC unavailable for <methodName>" and
that only one request is made, and (3) a persistent 503 case that asserts the
exception and verifies the retry count (3 requests) and fallback behavior;
locate the tests by referencing the existing test method names
shouldRetryOnTransientFailureThenSucceed, shouldNotRetryOnClientError,
shouldExhaustRetriesAndInvokeFallback and the adapter methods
getTransactionReceipt and getTokenBalance to implement analogous WireMock stubs
and assertions.


@Configuration
@EnableAspectJAutoProxy
@Import({RetryAutoConfiguration.class, CircuitBreakerAutoConfiguration.class})
static class TestConfig {

@Bean
EvmChainProperties evmChainProperties() {
return new EvmChainProperties(true, Map.of(
"base", new EvmChainProperties.ChainRpcConfig(
wireMock.baseUrl(), 84532L,
"0x036CbD53842c5426634e7929541eC2318f3dCF7e",
5000, 10000)
));
}

@Bean
EvmRpcAdapter evmRpcAdapter(EvmChainProperties properties) {
return new EvmRpcAdapter(properties);
}
}
}
Loading
Loading