Add circuit breaker - #15
Conversation
Update to the latest LTS version.
The previous approach of copying app start scripts from build directories to the root and adjusting them no longer works in newer Gradle versions. Therefore, there is no longer a need to depend on the bisq-gradle module.
Previously, the masked string would have the same length as the original string. This caused unnecessarily long masked strings to be logged. Now the masked string is reduced with a fixed mask length.
All push messages should contain encrypted content.
This attempts to reduce user-visible duplicates caused by retries of the same logical push notification.
- Rename integration test classes - Use MockitoBean rather than deprecated MockBean
86f811e to
d355fbf
Compare
rodvar
left a comment
There was a problem hiding this comment.
Very solid implementation overall @devinbileck - it feels like a big upgrade.
I see the PR resolves the issue referred in the description + implementing a full retry mechanism
Just weary how would this play out with the circuit breaking? Can you confirm / document how this is intended to work?
- Each retry counts as a separate failure against the breaker. With maxAttempts=3 and minimumNumberOfCalls=5, a real outage trips the breaker after ~2 logical sends instead of 5 — could be desirable, but it's not what the threshold values suggest..?
- Retry can amplify load during a partial outage (the very thing the breaker is trying to prevent) until the breaker opens.
- The non-goal wording in the issue suggests we wanted to address retry separately or not at all?
And also added some minor comments on the code hope you find them helpful !
| */ | ||
| private static boolean isBreakerRelevantApnsRejection(final String errorCode) { | ||
| return switch (errorCode) { | ||
| case "TooManyRequests", "ServiceUnavailable", "InternalServerError" -> true; |
There was a problem hiding this comment.
nit - use enum instead of hardcoded strings?
| false, | ||
| errorCode, | ||
| errorMessage, | ||
| messagingErrorCode == MessagingErrorCode.UNREGISTERED |
There was a problem hiding this comment.
can we add a test for this case?
same for auth-failure case
There was a problem hiding this comment.
Sure, I can take a look.
| @SpringBootTest | ||
| @AutoConfigureMockMvc | ||
| @ActiveProfiles("integrationtest") | ||
| class RelayControllerIT { |
There was a problem hiding this comment.
With the removal of RelayController into this class the RelayControllerTest has been lost with important validations (hex decoding, Bisq v1/v2 token format detection, missing-param errors) - consider bringing that back in?
There was a problem hiding this comment.
I thought I covered all the various validations here, but I will review it again.
| resilience4j.retry.configs.default.waitDuration=500ms | ||
| resilience4j.retry.configs.default.enableExponentialBackoff=true | ||
| resilience4j.retry.configs.default.exponentialBackoffMultiplier=2 | ||
| resilience4j.retry.configs.default.retryExceptions=\ |
There was a problem hiding this comment.
overlaps with recordExceptions is that intentional?
There was a problem hiding this comment.
Yes, the overlap is intentional. retryExceptions and recordExceptions serve different purposes: retry decides which failures are worth another attempt within the same logical send, while the circuit breaker decides which final failures should contribute to opening the breaker. The shared exceptions are transient transport/async failures, so they should be retried and, if still failing after retries/time limit, recorded by the breaker. ProviderFailureException is intentionally only in recordExceptions, not retryExceptions, so provider outage/throttle/server-failure signals count against the breaker without adding retry load.
| this.meterRegistryProvider = meterRegistryProvider; | ||
| } | ||
|
|
||
| @PostConstruct |
There was a problem hiding this comment.
I think this is minor given Spring lifecycle but worth noting: A CB transition that fires before the listener is attached is missed.
There was a problem hiding this comment.
Yes, a circuit breaker event emitted before the observability listener is attached would be missed. In this case the impact is limited to observability: state transition logs and the custom short-circuit counter could miss very early startup events, but breaker behavior itself is unaffected. In normal Spring lifecycle this should be unlikely because registration happens during context initialization before requests are served.
|
|
||
| // Wait for the OPEN wait duration to elapse | ||
| // This assumes waitDurationInOpenState <= 3s (in test properties) | ||
| Awaitility.await().pollDelay(Duration.ofSeconds(3)).until(() -> true); |
There was a problem hiding this comment.
consider changing to something like:
Awaitility.await()
.atMost(Duration.ofSeconds(5))
.until(() -> cb.tryAcquirePermission());
in all occurrences to avoid forcing a Thread.sleep(3000) when its not necessary
There was a problem hiding this comment.
Good point. The existing Awaitility usage was just a fixed delay and always waited the full 3 seconds. I'll replace it with a condition-based wait. I'm avoiding tryAcquirePermission() in the wait condition because it consumes a HALF_OPEN permit, which can affect the actual send being tested.
The current composition is CircuitBreaker --> TimeLimiter --> Retry --> provider call.
I will re-word that.
Yes! Thank you. |
|
@rodvar I believe I have addressed your feedback. Please review again when you get a chance. |
rodvar
left a comment
There was a problem hiding this comment.
utACK
all concerns have been addressed 💪
just added a small nit for your consideration, great stuff!
| // This assumes waitDurationInOpenState <= 3s (in test properties) | ||
| Awaitility.await() | ||
| .atMost(Duration.ofSeconds(3)) | ||
| .until(cb::tryAcquirePermission); |
There was a problem hiding this comment.
nit - wouldn't it be better to use cb.transitionToHalfOpenState and then do the probe?
There was a problem hiding this comment.
That would change the intent of the test - it should verify the OPEN wait-duration path. I’ll replace it with a passive wait for the configured open duration.
|
LGTM! |
Resolves #7