class_name AgonesSDK extends Node # Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns # the process-ready probe and /ready transition; this node owns the game # process's periodic Health pings and terminal Shutdown/annotation calls. const HEALTH_INTERVAL_SECONDS := 2.0 const REQUEST_TIMEOUT_SECONDS := 2.0 const MAX_ANNOTATION_VALUE_LENGTH := 4096 var _base_url := "" var _health_timer: Timer = null var _health_in_flight := false var _health_started_msec := 0 # Set when start_health() is called before this node is inside the tree, so # _ready() can arm the timer at the first moment it is legal to do so. var _health_pending := false # Health is armed here rather than by the caller. A Timer only ticks while its # owner is inside the SceneTree, so arming it from a caller that has not yet # parented this node produces a node that looks configured and never pings — # which is exactly how every allocated GameServer silently failed its Agones # health check and was recycled. func _ready() -> void: if _health_pending: _health_pending = false _arm_health() func configure_from_environment() -> bool: var port := OS.get_environment("AGONES_SDK_HTTP_PORT") if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535: return false _base_url = "http://127.0.0.1:%d" % int(port) return true func configure_for_testing(base_url: String) -> bool: if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"): return false _base_url = base_url.trim_suffix("/") return true func is_available() -> bool: return not _base_url.is_empty() # Returns whether health pings are running. It is a bool rather than void # because every way this can fail used to be silent, and a game server that # believes it is healthy while sending nothing is worse than one that refuses # to start: Agones recycles the former every ~20 seconds forever. func start_health() -> bool: if not is_available(): push_error("AgonesSDK: start_health() before configuration; no health pings will be sent") return false if _health_timer != null: return true if not is_inside_tree(): # Deferred rather than fatal: the caller may legitimately configure # before parenting. _ready() arms it. Still reported, because if the # node is never parented this is the whole failure. _health_pending = true push_warning("AgonesSDK: start_health() called outside the tree; deferring until ready") return false _arm_health() return true func _arm_health() -> void: if _health_timer != null: return _health_timer = Timer.new() _health_timer.name = "AgonesHealth" _health_timer.wait_time = HEALTH_INTERVAL_SECONDS _health_timer.one_shot = false _health_timer.timeout.connect(_send_health) add_child(_health_timer) _health_timer.start() _send_health() func health_is_running() -> bool: return _health_timer != null and is_inside_tree() func stop_health() -> void: if _health_timer != null: _health_timer.stop() _health_timer.queue_free() _health_timer = null func health() -> int: return await _request(HTTPClient.METHOD_POST, "/health", {}) func mark_ready() -> int: return await _request(HTTPClient.METHOD_POST, "/ready", {}) func shutdown() -> int: return await _request(HTTPClient.METHOD_POST, "/shutdown", {}) func set_annotation(key: String, value: String) -> int: if not annotation_is_valid(key, value): return 400 return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value}) static func annotation_is_valid(key: String, value: String) -> bool: return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value) func _send_health() -> void: if not is_available(): return # The latch stops overlapping requests, but it must never become permanent. # It is set across an await, and a request that never completes would # otherwise silence health for the lifetime of the process. HTTPRequest's # own timeout normally resolves this; the elapsed check is the backstop for # the case where request_completed never fires at all. if _health_in_flight: var stuck_for := Time.get_ticks_msec() - _health_started_msec if stuck_for < int(REQUEST_TIMEOUT_SECONDS * 2.0 * 1000.0): return push_warning("Agones health ping did not complete in %dms; sending another" % stuck_for) _health_in_flight = true _health_started_msec = Time.get_ticks_msec() var status := await health() _health_in_flight = false if status < 200 or status >= 300: push_warning("Agones health ping failed (%d)" % status) func _request(method: int, path: String, payload: Dictionary) -> int: if not is_available() or not path.begins_with("/"): return 408 var request := HTTPRequest.new() request.timeout = REQUEST_TIMEOUT_SECONDS add_child(request) var body := JSON.stringify(payload) var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body) if err != OK: request.queue_free() return 599 var result = await request.request_completed request.queue_free() return int(result[1])