fix: harden wallet balances and service startup

This commit is contained in:
2026-06-28 14:12:23 +03:00
parent bf8d778a9f
commit 73b5922c97
4 changed files with 55 additions and 12 deletions

View File

@@ -65,6 +65,8 @@ class Settings(BaseSettings):
CSRF_COOKIE_PATH: str = '/' CSRF_COOKIE_PATH: str = '/'
CSRF_COOKIE_DOMAIN: str | None = None CSRF_COOKIE_DOMAIN: str | None = None
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*elcsa\.ru(:\d+)?$'
DOCS_USERNAME: str = 'admin' DOCS_USERNAME: str = 'admin'
DOCS_PASSWORD: str = 'admin' DOCS_PASSWORD: str = 'admin'

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import time
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any from typing import Any
@@ -19,6 +20,8 @@ from src.infrastructure.wallet_balances.token_registry import (
) )
TIMEOUT_SECONDS = 15 TIMEOUT_SECONDS = 15
RETRY_ATTEMPTS = 2
PRICE_CACHE_SECONDS = 30
BLOCKSTREAM_URL = 'https://blockstream.info/api' BLOCKSTREAM_URL = 'https://blockstream.info/api'
TRONGRID_URL = 'https://api.trongrid.io' TRONGRID_URL = 'https://api.trongrid.io'
COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price' COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'
@@ -49,6 +52,10 @@ class WalletBalance:
class WalletBalanceService: class WalletBalanceService:
def __init__(self) -> None:
self._prices_cache: dict[tuple[tuple[str,str],...],tuple[float,dict[str,Decimal | None]]] = {}
async def get_wallet_balances(self, wallets) -> list[WalletBalance]: async def get_wallet_balances(self, wallets) -> list[WalletBalance]:
prices = await self._get_prices(wallets) prices = await self._get_prices(wallets)
results = await asyncio.gather( results = await asyncio.gather(
@@ -193,10 +200,17 @@ class WalletBalanceService:
for token in self._tokens_for_chain(chain): for token in self._tokens_for_chain(chain):
coin_id = get_coingecko_id(chain, token['symbol']) coin_id = get_coingecko_id(chain, token['symbol'])
if coin_id: if coin_id:
pairs[f'{chain}:{token['symbol']}'] = coin_id symbol = token['symbol']
pairs[f'{chain}:{symbol}'] = coin_id
if not pairs: if not pairs:
return {} return {}
cache_key = tuple(sorted(pairs.items()))
cached = self._prices_cache.get(cache_key)
now = time.monotonic()
if cached is not None and now - cached[0] < PRICE_CACHE_SECONDS:
return dict(cached[1])
try: try:
response = await self._get_json( response = await self._get_json(
COINGECKO_URL, COINGECKO_URL,
@@ -210,10 +224,12 @@ class WalletBalanceService:
except Exception: except Exception:
return {key: None for key in pairs} return {key: None for key in pairs}
return { prices = {
key: self._decimal_or_none((response.get(coin_id) or {}).get('usd')) key: self._decimal_or_none((response.get(coin_id) or {}).get('usd'))
for key, coin_id in pairs.items() for key, coin_id in pairs.items()
} }
self._prices_cache[cache_key] = (now,prices)
return prices
def _token_amounts( def _token_amounts(
self, self,
@@ -287,9 +303,16 @@ class WalletBalanceService:
timeout: int = TIMEOUT_SECONDS, timeout: int = TIMEOUT_SECONDS,
) -> Any: ) -> Any:
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, params=params, headers=headers) for attempt in range(RETRY_ATTEMPTS + 1):
response.raise_for_status() try:
return response.json() response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
if attempt >= RETRY_ATTEMPTS:
raise
await asyncio.sleep(0.2 * (attempt + 1))
raise RuntimeError('HTTP GET failed')
async def _post_json( async def _post_json(
self, self,
@@ -300,9 +323,16 @@ class WalletBalanceService:
timeout: int = TIMEOUT_SECONDS, timeout: int = TIMEOUT_SECONDS,
) -> Any: ) -> Any:
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload, headers=headers) for attempt in range(RETRY_ATTEMPTS + 1):
response.raise_for_status() try:
return response.json() response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
return response.json()
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
if attempt >= RETRY_ATTEMPTS:
raise
await asyncio.sleep(0.2 * (attempt + 1))
raise RuntimeError('HTTP POST failed')
def _tokens_for_chain(self, chain: str) -> list[dict]: def _tokens_for_chain(self, chain: str) -> list[dict]:
if chain in {'ETH', 'BSC'}: if chain in {'ETH', 'BSC'}:

View File

@@ -74,8 +74,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
path=settings.VAULT_CRYPTO_MASTER_KEY_PATH, path=settings.VAULT_CRYPTO_MASTER_KEY_PATH,
) )
logger.info('Crypto master key loaded from Vault') logger.info('Crypto master key loaded from Vault')
yield try:
logger.info(f'B2B service instance ended with id {instance_id}') yield
finally:
sched = getattr(app.state,'jwt_keys_scheduler',None)
if sched:
sched.shutdown(wait=False)
logger.info(f'B2B service instance ended with id {instance_id}')
app: FastAPI = FastAPI( app: FastAPI = FastAPI(
@@ -111,7 +116,7 @@ app.add_middleware(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[], allow_origins=[],
allow_origin_regex='.*', allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
allow_credentials=True, allow_credentials=True,
allow_methods=['*'], allow_methods=['*'],
allow_headers=['*'], allow_headers=['*'],

View File

@@ -1,3 +1,4 @@
from functools import lru_cache
from fastapi import Depends from fastapi import Depends
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.commands import ( from src.application.commands import (
@@ -54,13 +55,18 @@ def get_list_organization_wallets_command(
return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger) return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger)
@lru_cache(maxsize=1)
def _wallet_balance_service() -> WalletBalanceService:
return WalletBalanceService()
def get_get_organization_wallet_balances_command( def get_get_organization_wallet_balances_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrganizationWalletBalancesCommand: ) -> GetOrganizationWalletBalancesCommand:
return GetOrganizationWalletBalancesCommand( return GetOrganizationWalletBalancesCommand(
unit_of_work=unit_of_work, unit_of_work=unit_of_work,
balance_service=WalletBalanceService(), balance_service=_wallet_balance_service(),
logger=logger, logger=logger,
) )