Skip to content

Commit

Permalink
feat: implement circuit breaker with closed/open/half-open states (#51)
Browse files Browse the repository at this point in the history
- Add CircuitBreaker class in new network package with three states:
  CLOSED (requests pass), OPEN (requests blocked), HALF_OPEN (one probe)
- Trips after 3 consecutive heartbeat failures, 10s cooldown before probe
- Integrate into GameNetworkHandler: gates saveMove and sendHeartbeat
- Connection UI reflects breaker state (Disconnected/Reconnecting/latency)
  • Loading branch information
Francin Vincent committed Apr 6, 2026
1 parent 96800a0 commit defafb4
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.group14.regicidechess.network;

/**
* Simple circuit breaker with three states: CLOSED, OPEN, HALF_OPEN.
*
* CLOSED: All requests pass. Consecutive failures are counted.
* OPEN: Requests blocked. After cooldown, transitions to HALF_OPEN.
* HALF_OPEN: One probe request allowed. Success -> CLOSED, failure -> OPEN.
*/
public class CircuitBreaker {

public enum State { CLOSED, OPEN, HALF_OPEN }

private State state = State.CLOSED;
private int failureCount = 0;
private long openedAt = 0L;
private boolean halfOpenProbeUsed = false;

private final int failureThreshold;
private final long cooldownMs;

public CircuitBreaker(int failureThreshold, long cooldownMs) {
this.failureThreshold = failureThreshold;
this.cooldownMs = cooldownMs;
}

public synchronized boolean allowRequest() {
advanceIfCooldownElapsed();
switch (state) {
case CLOSED:
return true;
case HALF_OPEN:
if (!halfOpenProbeUsed) {
halfOpenProbeUsed = true;
return true;
}
return false;
case OPEN:
default:
return false;
}
}

public synchronized void recordSuccess() {
failureCount = 0;
state = State.CLOSED;
halfOpenProbeUsed = false;
}

public synchronized void recordFailure() {
failureCount++;
if (state == State.HALF_OPEN || failureCount >= failureThreshold) {
state = State.OPEN;
openedAt = System.currentTimeMillis();
halfOpenProbeUsed = false;
}
}

public synchronized State getState() {
advanceIfCooldownElapsed();
return state;
}

public synchronized void reset() {
state = State.CLOSED;
failureCount = 0;
openedAt = 0L;
halfOpenProbeUsed = false;
}

private void advanceIfCooldownElapsed() {
if (state == State.OPEN && System.currentTimeMillis() - openedAt >= cooldownMs) {
state = State.HALF_OPEN;
halfOpenProbeUsed = false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.group14.regicidechess.database.DatabaseManager;
import com.group14.regicidechess.network.CircuitBreaker;
import com.group14.regicidechess.model.Move;
import com.group14.regicidechess.model.Player;

Expand Down Expand Up @@ -48,6 +49,7 @@ public interface Listener {
// Connection UI refs — updated directly here to keep GameScreen clean
private final Image connectionIcon;
private final Label connectionLabel;
private final CircuitBreaker circuitBreaker = new CircuitBreaker(3, 10_000L);

public GameNetworkHandler(String gameId, Player localPlayer, Listener listener,
Image connectionIcon, Label connectionLabel) {
Expand All @@ -72,13 +74,16 @@ public void start() {
// Outgoing
// ─────────────────────────────────────────────────────────────────────────

/** Saves a completed move (with optional promotion) to Firebase. */
public void saveMove(Move move) {
if (!circuitBreaker.allowRequest()) {
Gdx.app.log("GameNetworkHandler", "Circuit breaker OPEN — move send blocked");
return;
}
DatabaseManager.getInstance().getApi().saveMove(gameId, move, () -> {});
}

/** Sends a heartbeat pulse. Call every HEARTBEAT_INTERVAL seconds from render(). */
public void sendHeartbeat() {
if (!circuitBreaker.allowRequest()) return;
DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite());
}

Expand Down Expand Up @@ -118,19 +123,20 @@ private void startGameOverListener() {
}

private void startHeartbeat() {
// Send our first beat immediately
DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite());
sendHeartbeat();

DatabaseManager.getInstance().getApi()
.listenForHeartbeat(
gameId,
!localPlayer.isWhite(), // watch opponent's heartbeat
!localPlayer.isWhite(),
HEARTBEAT_TIMEOUT_MS,
latency -> Gdx.app.postRunnable(() -> {
circuitBreaker.recordSuccess();
updateConnectionUI(latency);
listener.onHeartbeatLatency(latency);
}),
() -> Gdx.app.postRunnable(() -> {
circuitBreaker.recordFailure();
connectionIcon.setColor(Color.RED);
connectionLabel.setText("Lost");
listener.onOpponentDisconnected();
Expand All @@ -143,6 +149,17 @@ private void startHeartbeat() {
// ─────────────────────────────────────────────────────────────────────────

private void updateConnectionUI(long latencyMs) {
CircuitBreaker.State cbState = circuitBreaker.getState();
if (cbState == CircuitBreaker.State.OPEN) {
connectionIcon.setColor(Color.RED);
connectionLabel.setText("Disconnected");
return;
}
if (cbState == CircuitBreaker.State.HALF_OPEN) {
connectionIcon.setColor(Color.ORANGE);
connectionLabel.setText("Reconnecting...");
return;
}
if (latencyMs < 150) connectionIcon.setColor(Color.GREEN);
else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE);
else connectionIcon.setColor(Color.RED);
Expand Down

0 comments on commit defafb4

Please sign in to comment.