From 0eb570b28f069b54ea95dcaa7c90253ade5543b5 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:58:30 -0300 Subject: [PATCH] v3.0.6 --- .env.example | 1 + src/gw2/constants/gw2_messages.py | 4 + src/gw2/constants/gw2_settings.py | 1 + src/gw2/tools/gw2_client.py | 8 +- src/gw2/tools/gw2_utils.py | 68 ++++++++++ tests/unit/gw2/tools/test_gw2_utils.py | 166 +++++++++++++++++++++++-- 6 files changed, 237 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e3ebb2f..b3bbacb 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,4 @@ GW2_WVW_COOLDOWN=20 # GW2 API retry GW2_API_RETRY_MAX_ATTEMPTS=5 GW2_API_RETRY_DELAY=3.0 +GW2_API_SESSION_RETRY_BG_DELAY=30.0 diff --git a/src/gw2/constants/gw2_messages.py b/src/gw2/constants/gw2_messages.py index 34b7f0a..9e88750 100644 --- a/src/gw2/constants/gw2_messages.py +++ b/src/gw2/constants/gw2_messages.py @@ -115,6 +115,10 @@ def session_not_active(prefix: str) -> str: "There was a problem trying to record your last finished session.\n" "Please, do not close discord when the game is running." ) +SESSION_API_DOWN_DM = ( + "The GW2 API was unreachable while recording your session.\n" + "Your session data may be incomplete. Please try again later." +) USER_NO_SESSION_FOUND = ( "No records were found in your name.\n" "You are probably trying to execute this command without playing the game.\n" diff --git a/src/gw2/constants/gw2_settings.py b/src/gw2/constants/gw2_settings.py index 9ad10a5..d6aae99 100644 --- a/src/gw2/constants/gw2_settings.py +++ b/src/gw2/constants/gw2_settings.py @@ -30,6 +30,7 @@ class Gw2Settings(BaseSettings): # GW2 API retry api_retry_max_attempts: int | None = Field(default=5) api_retry_delay: float | None = Field(default=3.0) + api_session_retry_bg_delay: float | None = Field(default=30.0) @lru_cache(maxsize=1) diff --git a/src/gw2/tools/gw2_client.py b/src/gw2/tools/gw2_client.py index 89490e8..def2187 100644 --- a/src/gw2/tools/gw2_client.py +++ b/src/gw2/tools/gw2_client.py @@ -39,10 +39,14 @@ async def call_api(self, uri: str, key=None): max_attempts = _gw2_settings.api_retry_max_attempts retry_delay = _gw2_settings.api_retry_delay + clean_endpoint = endpoint.split("?")[0] + self.bot.log.debug(f"GW2 API call: {clean_endpoint}") + for attempt in range(1, max_attempts + 1): try: async with self.bot.aiosession.get(endpoint, headers=headers) as response: if response.status in (200, 206): + self.bot.log.debug(f"GW2 API response: {response.status} for {clean_endpoint}") return await response.json() if response.status not in _RETRYABLE_STATUSES or attempt == max_attempts: @@ -50,7 +54,7 @@ async def call_api(self, uri: str, key=None): return None self.bot.log.warning( - f"GW2 API returned {response.status} for {endpoint.split('?')[0]}, " + f"GW2 API returned {response.status} for {clean_endpoint}, " f"retrying ({attempt}/{max_attempts})..." ) except APIError: @@ -59,7 +63,7 @@ async def call_api(self, uri: str, key=None): if attempt == max_attempts: raise self.bot.log.warning( - f"GW2 API connection error for {endpoint.split('?')[0]}, retrying ({attempt}/{max_attempts})..." + f"GW2 API connection error for {clean_endpoint}, retrying ({attempt}/{max_attempts})..." ) await asyncio.sleep(retry_delay) diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 35e8fcd..211f278 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -1,3 +1,4 @@ +import asyncio import discord from datetime import datetime, timedelta from discord.ext import commands @@ -23,9 +24,12 @@ def __init__(self): from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.constants import gw2_messages from src.gw2.constants.gw2_currencies import ACHIEVEMENT_MAPPING, WALLET_MAPPING +from src.gw2.constants.gw2_settings import get_gw2_settings from src.gw2.constants.gw2_teams import get_team_name, is_wr_team_id from src.gw2.tools.gw2_client import Gw2Client +_gw2_settings = get_gw2_settings() + class Gw2Servers(Enum): Anvil_Rock = "Anvil Rock" @@ -274,6 +278,11 @@ async def check_gw2_game_activity(bot: Bot, before: discord.Member, after: disco after_activity = _get_non_custom_activity(after.activities) if _is_gw2_activity_detected(before_activity, after_activity): + bot.log.debug( + f"GW2 activity detected for {after.id}: " + f"before={before_activity.name if before_activity else None}, " + f"after={after_activity.name if after_activity else None}" + ) await _handle_gw2_activity_change(bot, after, after_activity) @@ -302,19 +311,23 @@ async def _handle_gw2_activity_change( server_configs = await gw2_configs.get_gw2_server_configs(member.guild.id) if not server_configs or not server_configs[0]["session"]: + bot.log.debug(f"Session tracking not enabled for guild {member.guild.id}, skipping") return gw2_key_dal = Gw2KeyDal(bot.db_session, bot.log) api_key_result = await gw2_key_dal.get_api_key_by_user(member.id) if not api_key_result: + bot.log.debug(f"No GW2 API key found for user {member.id}, skipping session") return api_key = api_key_result[0]["key"] if after_activity is not None: + bot.log.debug(f"Starting GW2 session for user {member.id}") await start_session(bot, member, api_key) else: + bot.log.debug(f"Ending GW2 session for user {member.id}") await end_session(bot, member, api_key) @@ -322,8 +335,15 @@ async def start_session(bot: Bot, member: discord.Member, api_key: str) -> None: """Start a new GW2 session for a member.""" session = await get_user_stats(bot, api_key) if not session: + bot.log.warning(f"Failed to start session for user {member.id}: unable to fetch stats from GW2 API") + asyncio.create_task(_retry_session_later(bot, member, api_key, "start")) return + await _do_start_session(bot, member, api_key, session) + + +async def _do_start_session(bot: Bot, member: discord.Member, api_key: str, session: dict) -> None: + """Execute start session DB operations.""" session["user_id"] = member.id session["date"] = bot_utils.convert_datetime_to_str_short(bot_utils.get_current_date_time()) @@ -336,8 +356,15 @@ async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None: """End a GW2 session for a member.""" session = await get_user_stats(bot, api_key) if not session: + bot.log.warning(f"Failed to end session for user {member.id}: unable to fetch stats from GW2 API") + asyncio.create_task(_retry_session_later(bot, member, api_key, "end")) return + await _do_end_session(bot, member, api_key, session) + + +async def _do_end_session(bot: Bot, member: discord.Member, api_key: str, session: dict) -> None: + """Execute end session DB operations.""" session["user_id"] = member.id session["date"] = bot_utils.convert_datetime_to_str_short(bot_utils.get_current_date_time()) @@ -351,6 +378,47 @@ async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None: await insert_session_char(bot, member, api_key, session_id, "end") +async def _retry_session_later( + bot: Bot, member: discord.Member, api_key: str, session_type: str +) -> None: + """Background task: wait and retry session, DM user on final failure.""" + bg_delay = _gw2_settings.api_session_retry_bg_delay + max_attempts = _gw2_settings.api_retry_max_attempts + + bot.log.warning( + f"Scheduling background retry for {session_type} session " + f"for user {member.id} ({max_attempts} attempts, {bg_delay}s delay)" + ) + + for attempt in range(1, max_attempts + 1): + await asyncio.sleep(bg_delay) + + session = await get_user_stats(bot, api_key) + if session: + bot.log.info( + f"Background retry succeeded for {session_type} session " + f"for user {member.id} on attempt {attempt}/{max_attempts}" + ) + if session_type == "start": + await _do_start_session(bot, member, api_key, session) + else: + await _do_end_session(bot, member, api_key, session) + return + + bot.log.warning( + f"Background retry {attempt}/{max_attempts} failed for " + f"{session_type} session for user {member.id}" + ) + + bot.log.error( + f"All background retries exhausted for {session_type} session for user {member.id}" + ) + try: + await member.send(gw2_messages.SESSION_API_DOWN_DM) + except discord.HTTPException: + bot.log.warning(f"Could not DM user {member.id} about GW2 API failure") + + async def get_user_stats(bot: Bot, api_key: str) -> dict | None: """Get comprehensive user statistics from GW2 API.""" gw2_api = Gw2Client(bot) diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index bac2b84..13876b0 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -9,11 +9,14 @@ TimeObject, _calculate_earned_points, _create_initial_user_stats, + _do_end_session, + _do_start_session, _fetch_achievement_data_in_batches, _get_non_custom_activity, _get_wvw_rank_prefix, _handle_gw2_activity_change, _is_gw2_activity_detected, + _retry_session_later, _update_achievement_stats, _update_wallet_stats, calculate_user_achiev_points, @@ -786,16 +789,16 @@ def mock_member(self): return member @pytest.mark.asyncio - async def test_get_user_stats_returns_none(self, mock_bot, mock_member): - """Test that None user stats returns early (lines 300-302).""" + async def test_get_user_stats_returns_none_schedules_bg_retry(self, mock_bot, mock_member): + """Test that None user stats schedules background retry task.""" with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: mock_stats.return_value = None - await start_session(mock_bot, mock_member, "api-key") + with patch("src.gw2.tools.gw2_utils.asyncio.create_task") as mock_create_task: + await start_session(mock_bot, mock_member, "api-key") - # Should not proceed to session dal - with patch("src.gw2.tools.gw2_utils.Gw2SessionsDal") as mock_dal: - mock_dal.assert_not_called() + mock_create_task.assert_called_once() + mock_bot.log.warning.assert_called_once() @pytest.mark.asyncio async def test_successful_start_session(self, mock_bot, mock_member): @@ -847,12 +850,16 @@ def mock_member(self): return member @pytest.mark.asyncio - async def test_get_user_stats_returns_none(self, mock_bot, mock_member): - """Test that None user stats returns early (lines 314-316).""" + async def test_get_user_stats_returns_none_schedules_bg_retry(self, mock_bot, mock_member): + """Test that None user stats schedules background retry task.""" with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: mock_stats.return_value = None - await end_session(mock_bot, mock_member, "api-key") + with patch("src.gw2.tools.gw2_utils.asyncio.create_task") as mock_create_task: + await end_session(mock_bot, mock_member, "api-key") + + mock_create_task.assert_called_once() + mock_bot.log.warning.assert_called_once() @pytest.mark.asyncio async def test_successful_end_session(self, mock_bot, mock_member): @@ -1913,3 +1920,144 @@ def test_known_currency_ids(self): assert WALLET_MAPPING[45] == "volatile_magic" assert WALLET_MAPPING[32] == "unbound_magic" assert WALLET_MAPPING[18] == "transmutation_charges" + + +class TestRetrySessionLater: + """Test cases for _retry_session_later background retry function.""" + + @pytest.fixture + def mock_bot(self): + """Create a mock bot.""" + bot = MagicMock() + bot.db_session = MagicMock() + bot.log = MagicMock() + return bot + + @pytest.fixture + def mock_member(self): + """Create a mock member.""" + member = MagicMock() + member.id = 12345 + member.send = AsyncMock() + return member + + @pytest.mark.asyncio + async def test_retry_succeeds_on_first_bg_attempt_start(self, mock_bot, mock_member): + """Test background retry succeeds on first attempt for start session.""" + session_data = {"acc_name": "TestUser.1234", "wvw_rank": 50} + + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep") as mock_sleep, + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._do_start_session") as mock_do_start, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 60.0 + mock_settings.api_retry_max_attempts = 5 + mock_stats.return_value = session_data + mock_do_start.return_value = None + + await _retry_session_later(mock_bot, mock_member, "api-key", "start") + + mock_sleep.assert_called_once_with(60.0) + mock_do_start.assert_called_once_with(mock_bot, mock_member, "api-key", session_data) + mock_member.send.assert_not_called() + + @pytest.mark.asyncio + async def test_retry_succeeds_on_first_bg_attempt_end(self, mock_bot, mock_member): + """Test background retry succeeds on first attempt for end session.""" + session_data = {"acc_name": "TestUser.1234", "wvw_rank": 50} + + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep") as mock_sleep, + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._do_end_session") as mock_do_end, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 60.0 + mock_settings.api_retry_max_attempts = 5 + mock_stats.return_value = session_data + mock_do_end.return_value = None + + await _retry_session_later(mock_bot, mock_member, "api-key", "end") + + mock_sleep.assert_called_once_with(60.0) + mock_do_end.assert_called_once_with(mock_bot, mock_member, "api-key", session_data) + mock_member.send.assert_not_called() + + @pytest.mark.asyncio + async def test_retry_succeeds_on_third_attempt(self, mock_bot, mock_member): + """Test background retry succeeds after multiple failed attempts.""" + session_data = {"acc_name": "TestUser.1234", "wvw_rank": 50} + + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep") as mock_sleep, + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._do_start_session") as mock_do_start, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 60.0 + mock_settings.api_retry_max_attempts = 5 + mock_stats.side_effect = [None, None, session_data] + mock_do_start.return_value = None + + await _retry_session_later(mock_bot, mock_member, "api-key", "start") + + assert mock_sleep.call_count == 3 + mock_do_start.assert_called_once_with(mock_bot, mock_member, "api-key", session_data) + mock_member.send.assert_not_called() + + @pytest.mark.asyncio + async def test_all_retries_exhausted_sends_dm(self, mock_bot, mock_member): + """Test DM is sent when all background retries are exhausted.""" + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep"), + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 60.0 + mock_settings.api_retry_max_attempts = 3 + mock_stats.return_value = None + + await _retry_session_later(mock_bot, mock_member, "api-key", "start") + + mock_member.send.assert_called_once() + sent_msg = mock_member.send.call_args[0][0] + assert "GW2 API was unreachable" in sent_msg + mock_bot.log.error.assert_called_once() + + @pytest.mark.asyncio + async def test_dm_failure_handled_gracefully(self, mock_bot, mock_member): + """Test that DM send failure is handled gracefully when user has DMs disabled.""" + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep"), + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 60.0 + mock_settings.api_retry_max_attempts = 2 + mock_stats.return_value = None + mock_member.send.side_effect = discord.HTTPException(MagicMock(), "Forbidden") + + await _retry_session_later(mock_bot, mock_member, "api-key", "end") + + mock_member.send.assert_called_once() + mock_bot.log.warning.assert_called() + + @pytest.mark.asyncio + async def test_retry_uses_correct_delay_and_attempts(self, mock_bot, mock_member): + """Test that retry uses configured delay and max attempts.""" + with ( + patch("src.gw2.tools.gw2_utils.asyncio.sleep") as mock_sleep, + patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats, + patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings, + ): + mock_settings.api_session_retry_bg_delay = 120.0 + mock_settings.api_retry_max_attempts = 2 + mock_stats.return_value = None + + await _retry_session_later(mock_bot, mock_member, "api-key", "start") + + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(120.0) + assert mock_stats.call_count == 2