Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/gw2/constants/gw2_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/gw2/constants/gw2_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/gw2/tools/gw2_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,22 @@ 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:
await self._handle_api_error(response, endpoint)
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:
Expand All @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions src/gw2/tools/gw2_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import discord
from datetime import datetime, timedelta
from discord.ext import commands
Expand All @@ -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"
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -302,28 +311,39 @@ 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)


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())

Expand All @@ -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())

Expand All @@ -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)
Expand Down
166 changes: 157 additions & 9 deletions tests/unit/gw2/tools/test_gw2_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
TimeObject,
_calculate_earned_points,
_create_initial_user_stats,
_do_end_session,

Check failure on line 12 in tests/unit/gw2/tools/test_gw2_utils.py

View workflow job for this annotation

GitHub Actions / Lint (ruff)

ruff (F401)

tests/unit/gw2/tools/test_gw2_utils.py:12:5: F401 `src.gw2.tools.gw2_utils._do_end_session` imported but unused
_do_start_session,

Check failure on line 13 in tests/unit/gw2/tools/test_gw2_utils.py

View workflow job for this annotation

GitHub Actions / Lint (ruff)

ruff (F401)

tests/unit/gw2/tools/test_gw2_utils.py:13:5: F401 `src.gw2.tools.gw2_utils._do_start_session` imported but unused
_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,
Expand Down Expand Up @@ -786,16 +789,16 @@
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):
Expand Down Expand Up @@ -847,12 +850,16 @@
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):
Expand Down Expand Up @@ -1913,3 +1920,144 @@
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
Loading