From 6d292fd5eb3fb04d70716de26b9f3ef4f97fce8f Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:06:42 -0600 Subject: [PATCH] fix(discord): retry slash sync after failed fingerprint --- plugins/platforms/discord/adapter.py | 20 +++++--- tests/gateway/test_discord_connect.py | 74 ++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8fcbcf3b8ce..5fab2307c30 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1773,18 +1773,24 @@ class DiscordAdapter(BasePlatformAdapter): if retry_after_until > now: remaining = max(1, int(retry_after_until - now)) return f"Discord asked us to wait before syncing slash commands; retry in {remaining}s" - if entry.get("fingerprint") == fingerprint and entry.get("last_success_at"): + last_success_at = float(entry.get("last_success_at") or 0) + last_attempt_at = float(entry.get("last_attempt_at") or 0) + if ( + entry.get("fingerprint") == fingerprint + and last_success_at + and last_success_at >= last_attempt_at + ): return "same slash-command fingerprint already synced" return None def _record_command_sync_attempt(self, app_id: Any, fingerprint: str) -> None: state = self._read_command_sync_state() - state[self._command_sync_state_key(app_id)] = { - **( - state.get(self._command_sync_state_key(app_id)) - if isinstance(state.get(self._command_sync_state_key(app_id)), dict) - else {} - ), + key = self._command_sync_state_key(app_id) + entry = state.get(key) if isinstance(state.get(key), dict) else {} + entry.pop("last_success_at", None) + entry.pop("summary", None) + state[key] = { + **entry, "fingerprint": fingerprint, "last_attempt_at": time.time(), } diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 22bae2c35d1..eb00280c26e 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -754,6 +754,72 @@ async def test_post_connect_initialization_skips_same_fingerprint_after_success( fake_http.upsert_global_command.assert_awaited_once() +@pytest.mark.asyncio +async def test_post_connect_initialization_retries_fingerprint_after_timeout(tmp_path, monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return { + "name": "skill", + "description": "Run a skill", + "type": 1, + "options": [], + } + + adapter._client = SimpleNamespace( + tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]), + application_id=999, + user=SimpleNamespace(id=999), + ) + desired_fingerprint = adapter._desired_command_sync_fingerprint() + state_path = ( + tmp_path + / discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR + / discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "999": { + "fingerprint": desired_fingerprint, + "last_attempt_at": 102.0, + "last_success_at": 101.0, + "summary": {"total": 1}, + } + } + ), + encoding="utf-8", + ) + + summary = { + "total": 1, + "unchanged": 0, + "updated": 0, + "recreated": 0, + "created": 1, + "deleted": 0, + } + sync = AsyncMock(side_effect=[asyncio.TimeoutError(), summary]) + monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync) + + await adapter._run_post_connect_initialization() + + timed_out_entry = json.loads(state_path.read_text(encoding="utf-8"))["999"] + assert timed_out_entry["fingerprint"] == desired_fingerprint + assert "last_success_at" not in timed_out_entry + assert "summary" not in timed_out_entry + + await adapter._run_post_connect_initialization() + + assert sync.await_count == 2 + recovered_entry = json.loads(state_path.read_text(encoding="utf-8"))["999"] + assert recovered_entry["last_success_at"] >= recovered_entry["last_attempt_at"] + assert recovered_entry["summary"] == summary + + @pytest.mark.asyncio async def test_post_connect_initialization_respects_discord_retry_after(tmp_path, monkeypatch): adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) @@ -788,7 +854,7 @@ async def test_post_connect_initialization_respects_discord_retry_after(tmp_path / discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR / discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME ) - state = json.loads(state_path.read_text()) + state = json.loads(state_path.read_text(encoding="utf-8")) entry = state["999"] assert entry["retry_after"] == 123.0 assert entry["retry_after_until"] > entry["last_attempt_at"] @@ -828,7 +894,11 @@ async def test_post_connect_initialization_reraises_non_rate_limit_exceptions(tm / discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR / discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME ) - state = json.loads(state_path.read_text()) if state_path.exists() else {} + state = ( + json.loads(state_path.read_text(encoding="utf-8")) + if state_path.exists() + else {} + ) entry = state.get("4242", {}) # Attempt was recorded before the sync call, but no rate-limit cooldown # should have been persisted from the unrelated exception.