diff --git a/guard_agent/_client_flush.py b/guard_agent/_client_flush.py index e4651c8..e41eeb4 100644 --- a/guard_agent/_client_flush.py +++ b/guard_agent/_client_flush.py @@ -27,6 +27,21 @@ def _fire_error_hook( ) -> None: fire_error_hook(self.config.on_error, self.logger, stage, exc, context) + def _retention_description(self, kind: str) -> str: + """Describe where unsent items wait for retry, based on whether a Redis + handler is attached to the buffer. Without Redis the items live only in + the in-memory buffer, so claiming Redis retention would be misleading.""" + has_redis = getattr(self.buffer, "redis_handler", None) is not None + memory_part = "requeued in memory" + redis_part = " and retained in Redis" if has_redis else "" + return f"{memory_part}{redis_part} ({kind})" + + def _events_retention_description(self) -> str: + return self._retention_description("events") + + def _metrics_retention_description(self) -> str: + return self._retention_description("metrics") + async def flush_buffer(self) -> None: try: await self._flush_events() @@ -74,7 +89,7 @@ async def _flush_events(self) -> None: if self._events_failure_streak == 1: self.logger.warning( f"Failed to send {len(events)} events; " - f"requeued in memory and retained in Redis for retry; " + f"{self._events_retention_description()} for retry; " f"backing off up to {delay:.0f}s between attempts" ) if exc is not None: @@ -122,7 +137,7 @@ async def _flush_metrics(self) -> None: if self._metrics_failure_streak == 1: self.logger.warning( f"Failed to send {len(metrics)} metrics; " - f"requeued in memory and retained in Redis for retry; " + f"{self._metrics_retention_description()} for retry; " f"backing off up to {delay:.0f}s between attempts" ) if exc is not None: diff --git a/tests/test_client.py b/tests/test_client.py index 2bd1b4c..1be82bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from collections.abc import Generator from datetime import datetime, timezone from typing import Any, cast @@ -1011,3 +1012,49 @@ def test_protocol_compliance(self, agent_config: AgentConfig) -> None: """Test that GuardAgentHandler satisfies AgentHandlerProtocol.""" handler = GuardAgentHandler(agent_config) assert isinstance(handler, AgentHandlerProtocol) + + +class TestRetentionLogMessage: + """The partial-failure warning must describe where unsent items actually + wait: memory only, or memory plus Redis when a Redis handler is attached.""" + + @pytest.mark.asyncio + async def test_events_warning_without_redis_says_memory_only( + self, agent_config: AgentConfig, caplog: pytest.LogCaptureFixture + ) -> None: + handler = GuardAgentHandler(agent_config) + handler.buffer = AsyncMock() + handler.buffer.redis_handler = None + handler.buffer.requeue_events_in_memory = AsyncMock(return_value=[]) + handler.buffer.flush_events_with_keys = AsyncMock( + return_value=([MagicMock()], ["ek"]) + ) + handler.transport = AsyncMock() + handler.transport.send_events = AsyncMock(return_value=False) + + with caplog.at_level(logging.WARNING, logger="guard_agent"): + await handler._flush_events() + + assert "requeued in memory (events) for retry" in caplog.text + assert "retained in Redis" not in caplog.text + + @pytest.mark.asyncio + async def test_events_warning_with_redis_mentions_redis( + self, agent_config: AgentConfig, caplog: pytest.LogCaptureFixture + ) -> None: + handler = GuardAgentHandler(agent_config) + handler.buffer = AsyncMock() + handler.buffer.redis_handler = AsyncMock() + handler.buffer.requeue_events_in_memory = AsyncMock(return_value=[]) + handler.buffer.flush_events_with_keys = AsyncMock( + return_value=([MagicMock()], ["ek"]) + ) + handler.transport = AsyncMock() + handler.transport.send_events = AsyncMock(return_value=False) + + with caplog.at_level(logging.WARNING, logger="guard_agent"): + await handler._flush_events() + + assert ( + "requeued in memory and retained in Redis (events) for retry" in caplog.text + )