Skip to content

feat: implement circuit breaker with closed/open/half-open states #60

Merged
merged 2 commits into from
Apr 8, 2026
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
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 @@ -5,6 +5,7 @@
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.group14.regicidechess.network.CircuitBreaker;
import com.group14.regicidechess.database.FirebaseAPI;
import com.group14.regicidechess.model.Move;
import com.group14.regicidechess.model.Player;
Expand Down Expand Up @@ -49,6 +50,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, FirebaseAPI api) {
Expand Down Expand Up @@ -78,13 +80,16 @@ public void stop() {
// 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;
}
api.saveMove(gameId, move, () -> {});
}

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

Expand Down Expand Up @@ -122,18 +127,18 @@ private void startGameOverListener() {
}

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

sendHeartbeat();
api.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 @@ -146,6 +151,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