-
Notifications
You must be signed in to change notification settings - Fork 0
feat(infra): add Resilience4j retry to all external adapters (STA-117) #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5238e76
5d98f3e
4947d3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same nested call proxy bypass as Solana adapter.
Also applies to: 83-83 🤖 Prompt for AI Agents |
||
| log.info("[EVM-RPC] Getting transaction receipt chain={} txHash={}", chainId.value(), txHash); | ||
|
|
@@ -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()); | ||
|
|
@@ -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={}", | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -68,6 +69,7 @@ public FireblocksCustodyAdapter(FireblocksProperties properties) { | |
| } | ||
|
|
||
| @Override | ||
| @Retry(name = "fireblocks", fallbackMethod = "signAndSubmitFallback") | ||
| @CircuitBreaker(name = "fireblocks", fallbackMethod = "signAndSubmitFallback") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| public SignResult signAndSubmit(SignRequest request) { | ||
|
Comment on lines
+72
to
74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes. With Resilience4j Spring annotations, the aspects are nested by default as:
Why a broad
How to avoid suppressing retries (per the same issue/docs):
Sources (official):
🏁 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.javaRepository: 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.javaRepository: Puneethkumarck/stablebridge-platform Length of output: 3816 Replace broad Both Applies to:
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 |
||
| log.info("[FIREBLOCKS] Signing and submitting transfer transferId={} chain={} to={}", | ||
|
|
@@ -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); | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nested RPC call bypasses retry for inner method.
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 |
||
| log.info("[SOLANA-RPC] Getting transaction chain={} signature={}", chainId.value(), txHash); | ||
|
|
@@ -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()); | ||
|
|
@@ -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={}", | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Same coverage suggestion: add tests for Consistent with the Solana test feedback—all three public methods have 🤖 Prompt for AI Agents |
||
|
|
||
| @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); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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-retryis 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