From 6ef55affcb1dbd88d98853f37b36099c35b68327 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:02:55 +0100 Subject: [PATCH] feat(multiplayer): add idempotent client mutation retries --- Game/scripts/control_plane_client.gd | 24 +++++++++++++++++++ Game/tests/cases/test_control_plane_client.gd | 9 +++++++ multiplayer-next.md | 2 ++ 3 files changed, 35 insertions(+) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 7973406e..ab37ae73 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -27,6 +27,8 @@ var assignment: AssignmentState var _request: HTTPRequest var _operation := "" var _last_queue_create: Dictionary = {} +var _last_mutation: Dictionary = {} +var _last_mutation_retryable := false var _websocket: WebSocketPeer var _websocket_status := "DISCONNECTED" var _websocket_retry_seconds := 0.0 @@ -159,6 +161,17 @@ func can_retry_queue_create() -> bool: return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id +func retry_last_mutation() -> Error: + if not can_retry_last_mutation(): + return ERR_INVALID_DATA + var request := _last_mutation.duplicate(true) + return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"])) + + +func can_retry_last_mutation() -> bool: + return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token) + + func recover_queue(ticket_id: String) -> Error: if ticket_id.is_empty(): return ERR_INVALID_PARAMETER @@ -256,6 +269,10 @@ static func is_valid_access_token(token: String) -> bool: return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n") +static func is_retryable_mutation_response(response_code: int) -> bool: + return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500 + + static func normalize_ticket(payload: Dictionary) -> Dictionary: var result := payload.duplicate(true) if result.has("expires_at") and result["expires_at"] is String: @@ -281,6 +298,9 @@ func _start_request(operation: String, method: HTTPClient.Method, path: String, if err != OK: _operation = "" return err + if not idempotency_key.is_empty(): + _last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision} + _last_mutation_retryable = false return OK @@ -288,6 +308,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head var operation := _operation _operation = "" if result != HTTPRequest.RESULT_SUCCESS: + _last_mutation_retryable = _last_mutation.get("operation", "") == operation if operation == "ranked_profile": ranked_profile.set_error("Ranked profile request failed") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": @@ -298,6 +319,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head return var parsed = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: + _last_mutation_retryable = _last_mutation.get("operation", "") == operation if operation == "ranked_profile": ranked_profile.set_error("Ranked profile returned invalid JSON") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": @@ -307,6 +329,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head request_failed.emit(operation, response_code, "invalid JSON") return if response_code < 200 or response_code >= 300: + _last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code) var detail := String(parsed.get("error", "request rejected")) var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty() if response_code == HTTPClient.RESPONSE_UNAUTHORIZED: @@ -334,6 +357,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head call_deferred("_run_pending_resync") return var payload: Dictionary = parsed + _last_mutation_retryable = false if operation == "steam_session": var returned_token := String(payload.get("access_token", "")) var returned_player_id := String(payload.get("player_id", "")) diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 8caa1177..82209efc 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -50,6 +50,15 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected") +func test_retryable_mutation_policy_only_retries_safe_failures() -> void: + assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable") + assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable") + assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable") + assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable") + assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed") + assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed") + + func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void: var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001") assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port") diff --git a/multiplayer-next.md b/multiplayer-next.md index 98bfcb12..6a6502a2 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1438,3 +1438,5 @@ The allocated supervisor now calls that shutdown acknowledgment during signal-bo Allocator-selected region, build, protocol, and transport now travel with the allocation as Agones annotations and override stale child launch flags immediately before an allocated process starts. The overlay rejects control characters and preserves direct-server command behavior; focused supervisor/allocator tests cover precedence and annotation payloads, while live Agones passthrough remains an infrastructure gate. The same allocation path now carries the matcher-selected playlist, preventing a ranked match from inheriting the Fleet’s casual default. Durable allocation claims return the playlist, the worker includes it in Fleet selection metadata, Agones copies it to the allocated GameServer, and the supervisor overrides `--playlist` before launch; the existing compatibility tests remain green. + +The Godot control-plane client now retains the exact last idempotent mutation and exposes `retry_last_mutation()` for transport, timeout, rate-limit, and 5xx failures. Retries reuse the original idempotency key and expected revision, while 401 and 409 responses remain non-retryable; the harness covers the policy boundary. This closes the local duplicate-action recovery mechanism for heartbeat/cancel/proposal calls, with broader live UI retry verification still remaining.