Skip to content

Commit

Permalink
Merge pull request #60 from TDT4240-14/feature/circuit-breaker
Browse files Browse the repository at this point in the history
feat: implement circuit breaker with closed/open/half-open states
  • Loading branch information
jonasew authored Apr 8, 2026
2 parents aca5c04 + 96edb8d commit b21877f
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 6 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 @@ -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

0 comments on commit b21877f

Please sign in to comment.