feat(multiplayer): add idempotent client mutation retries

This commit is contained in:
Josh Creek
2026-09-01 17:02:55 +01:00
parent 9ae01ecc5a
commit 6ef55affcb
3 changed files with 35 additions and 0 deletions
+24
View File
@@ -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", ""))