From defafb4b9a21e30bad006240ca13d5246dfd99d9 Mon Sep 17 00:00:00 2001 From: Francin Vincent Date: Mon, 6 Apr 2026 22:55:08 +0200 Subject: [PATCH] feat: implement circuit breaker with closed/open/half-open states (#51) - 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) --- .../regicidechess/network/CircuitBreaker.java | 77 +++++++++++++++++++ .../screens/game/GameNetworkHandler.java | 27 +++++-- 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java diff --git a/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java new file mode 100644 index 0000000..8fab597 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java @@ -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; + } + } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java index 4a70618..5839faf 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java @@ -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; @@ -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) { @@ -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()); } @@ -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(); @@ -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);