From 42534605b7c7574c5d05d6bba60bf128f08db790 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Sat, 2 May 2026 13:56:59 +0800 Subject: [PATCH] fix(slack): throttle channel directory warnings --- gateway/channel_directory.py | 42 +++++++++++++++++++------ tests/gateway/test_channel_directory.py | 27 ++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ff207d86cb3..939ab20ffc0 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -9,6 +9,7 @@ action="list" and for resolving human-friendly channel names to numeric IDs. import asyncio import json import logging +import time from datetime import datetime from typing import Any, Dict, List, Optional @@ -18,6 +19,14 @@ from utils import atomic_json_write logger = logging.getLogger(__name__) DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" +# Throttle window for repeated Slack channel-directory refresh failures. +# The directory rebuilds on a timer, so a persistent workspace error (e.g. +# missing scope, revoked token) would otherwise re-log the same warning on +# every refresh. Warn once per (team, error detail) per interval; repeats +# drop to DEBUG. +_SLACK_DIRECTORY_WARNING_INTERVAL_SECONDS = 3600 +_slack_directory_warning_last: Dict[tuple[str, str], float] = {} + # User-maintained friendly-name overlay. The directory is fully regenerated # from live adapters + session data on a timer, so hand-edits to # channel_directory.json don't survive. Aliases declared here are re-applied @@ -105,6 +114,27 @@ def _session_entry_name(origin: Dict[str, Any]) -> str: return f"{base_name} / {topic_label}" +def _warn_slack_directory(team_id: str, detail: str) -> None: + """Warn once per team/error per interval for recurring Slack refresh failures.""" + key = (str(team_id), str(detail)) + now = time.monotonic() + last = _slack_directory_warning_last.get(key) + if last is None or now - last >= _SLACK_DIRECTORY_WARNING_INTERVAL_SECONDS: + _slack_directory_warning_last[key] = now + logger.warning( + "Channel directory: failed to list Slack channels for team %s: %s", + team_id, + detail, + ) + else: + logger.debug( + "Channel directory: suppressed repeated Slack channel list failure " + "for team %s: %s", + team_id, + detail, + ) + + # --------------------------------------------------------------------------- # Build / refresh # --------------------------------------------------------------------------- @@ -240,11 +270,8 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: cursor=cursor, ) if not response.get("ok"): - logger.warning( - "Channel directory: users.conversations not ok for team %s: %s", - team_id, - response.get("error", "unknown"), - ) + detail = f"users.conversations not ok: {response.get('error', 'unknown')}" + _warn_slack_directory(team_id, detail) break for ch in response.get("channels", []): cid = ch.get("id") @@ -261,10 +288,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: if not cursor: break except Exception as e: - logger.warning( - "Channel directory: failed to list Slack channels for team %s: %s", - team_id, e, - ) + _warn_slack_directory(team_id, str(e)) continue # Merge in DM/group entries discovered from session history. diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index b30713163a2..488655f5ac8 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -16,6 +16,7 @@ from gateway.channel_directory import ( _apply_channel_aliases, _build_from_sessions, _build_slack, + _slack_directory_warning_last, ) @@ -576,6 +577,32 @@ class TestBuildSlack: assert entries == [] + def test_repeated_workspace_errors_are_warning_throttled( + self, tmp_path, caplog, monkeypatch + ): + client = _make_slack_client([ + {"ok": False, "error": "missing_scope"}, + {"ok": False, "error": "missing_scope"}, + ]) + _slack_directory_warning_last.clear() + monkeypatch.setattr("gateway.channel_directory.time.monotonic", lambda: 1000.0) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}), caplog.at_level("DEBUG"): + asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) + asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) + + warning_messages = [ + record.getMessage() + for record in caplog.records + if record.levelname == "WARNING" + ] + assert len(warning_messages) == 1 + assert "missing_scope" in warning_messages[0] + assert any( + "suppressed repeated Slack channel list failure" in record.getMessage() + for record in caplog.records + ) + class TestChannelAliases: """The user-maintained alias overlay (channel_aliases.json) gives durable