From 6d344a47f45cbb20456c1aeb7ecd81bfceb690c2 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Mon, 4 May 2026 17:14:21 +0300 Subject: [PATCH 1/3] Fix 403 auth error --- core/pb_client.py | 118 ++++++++++++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/core/pb_client.py b/core/pb_client.py index adf4b87..8cbfc45 100644 --- a/core/pb_client.py +++ b/core/pb_client.py @@ -75,6 +75,11 @@ async def _hdrs() -> dict[str, str]: return {"Authorization": await _ensure_auth()} +def _invalidate_token() -> None: + global _token_expiry + _token_expiry = 0.0 + + # --------------------------------------------------------------------------- # CRUD helpers # --------------------------------------------------------------------------- @@ -82,54 +87,70 @@ async def _hdrs() -> dict[str, str]: async def get_record(user_id: str) -> dict[str, Any] | None: """Fetch one economy record by Discord user_id. Returns None if not found.""" session = _get_session() - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"filter": f'user_id="{user_id}"', "perPage": 1}, - headers=await _hdrs(), - ) as resp: - resp.raise_for_status() - data = await resp.json() - items = data.get("items", []) - return items[0] if items else None + for attempt in range(2): + async with session.get( + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"filter": f'user_id="{user_id}"', "perPage": 1}, + headers=await _hdrs(), + ) as resp: + if resp.status == 403 and attempt == 0: + _invalidate_token() + continue + resp.raise_for_status() + data = await resp.json() + items = data.get("items", []) + return items[0] if items else None async def create_record(record: dict[str, Any]) -> dict[str, Any]: """Create a new economy record. Returns the created record (includes PB id).""" session = _get_session() - async with session.post( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - json=record, - headers=await _hdrs(), - ) as resp: - if resp.status not in (200, 201): - text = await resp.text() - raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}") - return await resp.json() + for attempt in range(2): + async with session.post( + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + json=record, + headers=await _hdrs(), + ) as resp: + if resp.status == 403 and attempt == 0: + _invalidate_token() + continue + if resp.status not in (200, 201): + text = await resp.text() + raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}") + return await resp.json() async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]: """PATCH an existing record by its PocketBase record id.""" session = _get_session() - async with session.patch( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", - json=data, - headers=await _hdrs(), - ) as resp: - resp.raise_for_status() - return await resp.json() + for attempt in range(2): + async with session.patch( + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", + json=data, + headers=await _hdrs(), + ) as resp: + if resp.status == 403 and attempt == 0: + _invalidate_token() + continue + resp.raise_for_status() + return await resp.json() async def count_records() -> int: """Return the total number of records in the collection (single cheap request).""" session = _get_session() - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"perPage": 1, "page": 1}, - headers=await _hdrs(), - ) as resp: - resp.raise_for_status() - data = await resp.json() - return int(data.get("totalItems", 0)) + for attempt in range(2): + async with session.get( + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"perPage": 1, "page": 1}, + headers=await _hdrs(), + ) as resp: + if resp.status == 403 and attempt == 0: + _invalidate_token() + continue + resp.raise_for_status() + data = await resp.json() + return int(data.get("totalItems", 0)) async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: @@ -137,18 +158,23 @@ async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] page = 1 session = _get_session() - hdrs = await _hdrs() while True: - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"perPage": page_size, "page": page}, - headers=hdrs, - ) as resp: - resp.raise_for_status() - data = await resp.json() - batch = data.get("items", []) - results.extend(batch) - if len(batch) < page_size: + hdrs = await _hdrs() + for attempt in range(2): + async with session.get( + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"perPage": page_size, "page": page}, + headers=hdrs, + ) as resp: + if resp.status == 403 and attempt == 0: + _invalidate_token() + hdrs = await _hdrs() + continue + resp.raise_for_status() + data = await resp.json() + batch = data.get("items", []) + results.extend(batch) + if len(batch) < page_size: + return results + page += 1 break - page += 1 - return results From 192888625ea451b649ed7702af1603aa58d911c7 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Mon, 4 May 2026 17:31:16 +0300 Subject: [PATCH 2/3] Refactor db error catch to one helper --- core/economy.py | 6 +- core/pb_client.py | 162 ++++++++++++++++++++++------------------------ 2 files changed, 79 insertions(+), 89 deletions(-) diff --git a/core/economy.py b/core/economy.py index 4bfc670..12f1599 100644 --- a/core/economy.py +++ b/core/economy.py @@ -16,17 +16,13 @@ from typing import TypedDict import aiohttp from . import pb_client +from .pb_client import DatabaseError import strings _txn_log = logging.getLogger("tipiCOIN.txn") -class DatabaseError(Exception): - """Raised when PocketBase is unreachable or returns an error.""" - pass - - def _txn(event: str, **fields) -> None: """Log a single economy transaction to the transactions logger.""" body = " ".join(f"{k}={v}" for k, v in fields.items()) diff --git a/core/pb_client.py b/core/pb_client.py index 8cbfc45..b15a495 100644 --- a/core/pb_client.py +++ b/core/pb_client.py @@ -21,6 +21,11 @@ import aiohttp import config + +class DatabaseError(Exception): + """Raised when PocketBase is unreachable or returns an error.""" + pass + _log = logging.getLogger("tipiCOIN.pb") PB_URL = config.PB_URL @@ -57,17 +62,20 @@ async def _ensure_auth() -> str: if time.monotonic() < _token_expiry: return _token session = _get_session() - async with session.post( - f"{PB_URL}/api/collections/_superusers/auth-with-password", - json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, - ) as resp: - if resp.status != 200: - text = await resp.text() - raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}") - data = await resp.json() - _token = data["token"] - _token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry - _log.debug("PocketBase admin token refreshed") + try: + async with session.post( + f"{PB_URL}/api/collections/_superusers/auth-with-password", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + ) as resp: + if resp.status != 200: + text = await resp.text() + raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}") + data = await resp.json() + _token = data["token"] + _token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry + _log.debug("PocketBase admin token refreshed") + except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e: + raise DatabaseError(f"Database unavailable: {e}") from e return _token @@ -80,101 +88,87 @@ def _invalidate_token() -> None: _token_expiry = 0.0 +# --------------------------------------------------------------------------- +# Request helper with auth-retry and error wrapping +# --------------------------------------------------------------------------- + +async def _request(method: str, url: str, **kwargs: Any) -> Any: + """Make an authenticated request, retrying once on 401/403 by re-authing. + + Returns the parsed JSON body. Raises DatabaseError on connection issues or + non-2xx responses after retrying. + """ + session = _get_session() + for attempt in range(2): + kwargs["headers"] = await _hdrs() + try: + async with session.request(method, url, **kwargs) as resp: + if resp.status in (401, 403) and attempt == 0: + _invalidate_token() + continue + if not resp.ok: + text = await resp.text() + raise DatabaseError(f"Database unavailable: {resp.status}, {text}") + return await resp.json() + except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e: + raise DatabaseError(f"Database unavailable: {e}") from e + + # --------------------------------------------------------------------------- # CRUD helpers # --------------------------------------------------------------------------- async def get_record(user_id: str) -> dict[str, Any] | None: """Fetch one economy record by Discord user_id. Returns None if not found.""" - session = _get_session() - for attempt in range(2): - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"filter": f'user_id="{user_id}"', "perPage": 1}, - headers=await _hdrs(), - ) as resp: - if resp.status == 403 and attempt == 0: - _invalidate_token() - continue - resp.raise_for_status() - data = await resp.json() - items = data.get("items", []) - return items[0] if items else None + data = await _request( + "GET", + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"filter": f'user_id="{user_id}"', "perPage": 1}, + ) + items = data.get("items", []) + return items[0] if items else None async def create_record(record: dict[str, Any]) -> dict[str, Any]: """Create a new economy record. Returns the created record (includes PB id).""" - session = _get_session() - for attempt in range(2): - async with session.post( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - json=record, - headers=await _hdrs(), - ) as resp: - if resp.status == 403 and attempt == 0: - _invalidate_token() - continue - if resp.status not in (200, 201): - text = await resp.text() - raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}") - return await resp.json() + return await _request( + "POST", + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + json=record, + ) async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]: """PATCH an existing record by its PocketBase record id.""" - session = _get_session() - for attempt in range(2): - async with session.patch( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", - json=data, - headers=await _hdrs(), - ) as resp: - if resp.status == 403 and attempt == 0: - _invalidate_token() - continue - resp.raise_for_status() - return await resp.json() + return await _request( + "PATCH", + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", + json=data, + ) async def count_records() -> int: """Return the total number of records in the collection (single cheap request).""" - session = _get_session() - for attempt in range(2): - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"perPage": 1, "page": 1}, - headers=await _hdrs(), - ) as resp: - if resp.status == 403 and attempt == 0: - _invalidate_token() - continue - resp.raise_for_status() - data = await resp.json() - return int(data.get("totalItems", 0)) + data = await _request( + "GET", + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"perPage": 1, "page": 1}, + ) + return int(data.get("totalItems", 0)) async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: """Fetch every record in the collection, handling PocketBase pagination.""" results: list[dict[str, Any]] = [] page = 1 - session = _get_session() while True: - hdrs = await _hdrs() - for attempt in range(2): - async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"perPage": page_size, "page": page}, - headers=hdrs, - ) as resp: - if resp.status == 403 and attempt == 0: - _invalidate_token() - hdrs = await _hdrs() - continue - resp.raise_for_status() - data = await resp.json() - batch = data.get("items", []) - results.extend(batch) - if len(batch) < page_size: - return results - page += 1 - break + data = await _request( + "GET", + f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + params={"perPage": page_size, "page": page}, + ) + batch = data.get("items", []) + results.extend(batch) + if len(batch) < page_size: + return results + page += 1 From 2c2621d24e2a03239933575ecddd06b0a3e04949 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Mon, 4 May 2026 17:48:55 +0300 Subject: [PATCH 3/3] Make exp gains less grindy --- core/economy.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/economy.py b/core/economy.py index 12f1599..368def9 100644 --- a/core/economy.py +++ b/core/economy.py @@ -317,16 +317,16 @@ LEVEL_ROLES: list[tuple[int, str]] = [ def get_level(exp: int) -> int: - """Level = max(1, floor(sqrt(exp/10))). - Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000.""" - return max(1, int(math.sqrt(max(0, exp) / 10))) + """Level = max(1, floor(sqrt(exp/6))). + Level 5 @ 150 EXP, 10 @ 600, 20 @ 2400, 30 @ 5400.""" + return max(1, int(math.sqrt(max(0, exp) / 6))) def exp_for_level(level: int) -> int: - """Minimum cumulative EXP to reach this level.""" + """Minimum cumulative EXP to reach this level. level^2 * 6.""" if level <= 1: return 0 - return level * level * 10 + return level * level * 6 def level_role_name(level: int) -> str: