Skip to content

Add circuit breaker - #15

Open
devinbileck wants to merge 16 commits into
bisq-network:mainfrom
devinbileck:add-circuit-breaker
Open

Add circuit breaker#15
devinbileck wants to merge 16 commits into
bisq-network:mainfrom
devinbileck:add-circuit-breaker

Conversation

@devinbileck

Copy link
Copy Markdown
Contributor

Resolves #7

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
@devinbileck
devinbileck force-pushed the add-circuit-breaker branch from 86f811e to d355fbf Compare April 23, 2026 04:51
@devinbileck
devinbileck marked this pull request as ready for review April 28, 2026 14:45

@rodvar rodvar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit - use enum instead of hardcoded strings?

false,
errorCode,
errorMessage,
messagingErrorCode == MessagingErrorCode.UNREGISTERED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we add a test for this case?
same for auth-failure case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure, I can take a look.

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integrationtest")
class RelayControllerIT {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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=\

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

overlaps with recordExceptions is that intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is minor given Spring lifecycle but worth noting: A CB transition that fires before the listener is attached is missed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@devinbileck

Copy link
Copy Markdown
Contributor Author

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 current composition is CircuitBreaker --> TimeLimiter --> Retry --> provider call.
Since the breaker is the outermost decorator, it observes one final outcome per logical send, not each retry attempt. So maxAttempts=3 does not make minimumNumberOfCalls=5 trip after ~2 logical sends; it still takes about 5 failed logical sends before the breaker evaluates/opens.
Retry can still amplify provider traffic while the breaker is closed, because a logical send can perform up to 3 attempts. That is intentional but bounded by retry max attempts, exponential backoff, the 5s time limiter, and the breaker preventing calls entirely once open.
I can add documentation near the composition/config to make that explicit.

  • The non-goal wording in the issue suggests we wanted to address retry separately or not at all?

I will re-word that.

And also added some minor comments on the code hope you find them helpful !

Yes! Thank you.

@devinbileck

Copy link
Copy Markdown
Contributor Author

@rodvar I believe I have addressed your feedback. Please review again when you get a chance.

@rodvar rodvar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit - wouldn't it be better to use cb.transitionToHalfOpenState and then do the probe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@rodvar

rodvar commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

LGTM!

This was referenced Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement a circuit breaker pattern to guard against flooding the APNs/FCM services

2 participants