fix: harden Remnawave panel timeouts
This commit is contained in:
@@ -101,6 +101,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"Секретный ключ API панели.",
|
"Секретный ключ API панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API total timeout",
|
||||||
|
"Maximum total time for one Remnawave API request, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API connect timeout",
|
||||||
|
"Maximum time to get or open a Remnawave API connection, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API socket connect timeout",
|
||||||
|
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API socket read timeout",
|
||||||
|
"Maximum time to wait for response data from Remnawave API, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_WEBHOOK_SECRET",
|
"PANEL_WEBHOOK_SECRET",
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class PanelApiService:
|
|||||||
_TRANSIENT_STATUS_CODES = (-1, -3)
|
_TRANSIENT_STATUS_CODES = (-1, -3)
|
||||||
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
||||||
_RETRY_BACKOFF_SECONDS = 0.5
|
_RETRY_BACKOFF_SECONDS = 0.5
|
||||||
|
_MIN_TIMEOUT_SECONDS = 0.1
|
||||||
|
_DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0
|
||||||
|
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||||
|
_DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||||
|
_DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0
|
||||||
|
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -70,17 +75,46 @@ class PanelApiService:
|
|||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
if self._session is None or self._session.closed:
|
if self._session is None or self._session.closed:
|
||||||
# Separate connect/read timeouts so a stuck panel does not hold a
|
self._session = aiohttp.ClientSession(timeout=self._client_timeout())
|
||||||
# bot worker for the full window; total caps worst-case latency.
|
|
||||||
timeout = aiohttp.ClientTimeout(
|
|
||||||
total=15,
|
|
||||||
connect=3,
|
|
||||||
sock_connect=3,
|
|
||||||
sock_read=10,
|
|
||||||
)
|
|
||||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float:
|
||||||
|
raw_value = getattr(settings, name, default)
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
if value <= 0:
|
||||||
|
return default
|
||||||
|
return max(cls._MIN_TIMEOUT_SECONDS, value)
|
||||||
|
|
||||||
|
def _client_timeout(self) -> aiohttp.ClientTimeout:
|
||||||
|
# Separate connect/read timeouts so a slow panel route has more room,
|
||||||
|
# while genuinely stuck requests still cannot pin a worker forever.
|
||||||
|
return aiohttp.ClientTimeout(
|
||||||
|
total=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_TOTAL_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
connect=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
sock_connect=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
sock_read=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def close_session(self):
|
async def close_session(self):
|
||||||
if self._session and not self._session.closed:
|
if self._session and not self._session.closed:
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
@@ -121,6 +155,15 @@ class PanelApiService:
|
|||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
|
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
|
||||||
if attempt + 1 < max_attempts and self._is_transient_error(result):
|
if attempt + 1 < max_attempts and self._is_transient_error(result):
|
||||||
|
logging.warning(
|
||||||
|
"Retrying transient Panel API request method=%s endpoint=%s "
|
||||||
|
"attempt=%s/%s status_code=%s",
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
attempt + 1,
|
||||||
|
max_attempts,
|
||||||
|
result.get("status_code") if isinstance(result, dict) else None,
|
||||||
|
)
|
||||||
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
|
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
|
||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
@@ -158,8 +201,8 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||||
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
started = time.monotonic()
|
|
||||||
async with aiohttp_session.request(
|
async with aiohttp_session.request(
|
||||||
method.upper(), url_for_request, headers=headers, **kwargs
|
method.upper(), url_for_request, headers=headers, **kwargs
|
||||||
) as response:
|
) as response:
|
||||||
@@ -228,15 +271,48 @@ class PanelApiService:
|
|||||||
return {"error": True, "status_code": response_status, "details": error_details}
|
return {"error": True, "status_code": response_status, "details": error_details}
|
||||||
|
|
||||||
except aiohttp.ClientConnectorError as e:
|
except aiohttp.ClientConnectorError as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
||||||
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
|
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
|
||||||
|
except aiohttp.ServerTimeoutError as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
logging.warning("Panel API timeout to %s: %s", url_for_request, e)
|
||||||
|
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
logging.exception("Panel API ClientError to %s.", url_for_request)
|
||||||
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
logging.error(f"Panel API request to {url_for_request} timed out.")
|
logging.error(f"Panel API request to {url_for_request} timed out.")
|
||||||
return {"error": True, "status_code": -3, "message": "Request timed out"}
|
return {"error": True, "status_code": -3, "message": "Request timed out"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
|
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
@@ -885,7 +961,14 @@ class PanelApiService:
|
|||||||
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
|
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
|
||||||
|
|
||||||
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
||||||
|
if squads is not None:
|
||||||
|
return squads
|
||||||
|
stale_squads = self._squads_cache.get_stale("list")
|
||||||
|
if stale_squads is not None:
|
||||||
|
logging.warning("Using stale internal squads cache after panel fetch failed.")
|
||||||
|
return stale_squads
|
||||||
|
return None
|
||||||
|
|
||||||
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
|
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ class AsyncTTLCache:
|
|||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def get_stale(self, key: str) -> Optional[Any]:
|
||||||
|
entry = self._data.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
_, value = entry
|
||||||
|
if not self._is_cacheable(value):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_cacheable(value: Any) -> bool:
|
def _is_cacheable(value: Any) -> bool:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ class Settings(BaseSettings):
|
|||||||
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
|
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
|
||||||
|
PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25)
|
||||||
|
PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||||
|
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||||
|
PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15)
|
||||||
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
||||||
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
|
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
|
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
|
||||||
|
|||||||
@@ -59,6 +59,10 @@
|
|||||||
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
|
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
|
||||||
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
|
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
|
||||||
| `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. |
|
| `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. |
|
||||||
|
| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. |
|
||||||
|
| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. |
|
||||||
|
| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. |
|
||||||
|
| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. |
|
||||||
| `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. |
|
| `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. |
|
||||||
| `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
|
| `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
|
||||||
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
|
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
|
||||||
|
|||||||
@@ -1544,6 +1544,14 @@
|
|||||||
"admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.",
|
"admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.",
|
||||||
"admin_settings_field_panel_api_key_label": "Remnawave API key",
|
"admin_settings_field_panel_api_key_label": "Remnawave API key",
|
||||||
"admin_settings_field_panel_api_key_description": "Secret API key for the panel.",
|
"admin_settings_field_panel_api_key_description": "Secret API key for the panel.",
|
||||||
|
"admin_settings_field_panel_api_total_timeout_seconds_label": "Remnawave API total timeout",
|
||||||
|
"admin_settings_field_panel_api_total_timeout_seconds_description": "Maximum total time for one Remnawave API request, in seconds.",
|
||||||
|
"admin_settings_field_panel_api_connect_timeout_seconds_label": "Remnawave API connect timeout",
|
||||||
|
"admin_settings_field_panel_api_connect_timeout_seconds_description": "Maximum time to get or open a Remnawave API connection, in seconds.",
|
||||||
|
"admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "Remnawave API TCP/TLS timeout",
|
||||||
|
"admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Maximum TCP/TLS connection time for Remnawave API, in seconds.",
|
||||||
|
"admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Remnawave API read timeout",
|
||||||
|
"admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Maximum time to wait for response data from Remnawave API, in seconds.",
|
||||||
"admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret",
|
"admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret",
|
||||||
"admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.",
|
"admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.",
|
||||||
"admin_settings_field_user_squad_uuids_label": "Default Internal Squads",
|
"admin_settings_field_user_squad_uuids_label": "Default Internal Squads",
|
||||||
|
|||||||
@@ -1544,6 +1544,14 @@
|
|||||||
"admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.",
|
"admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.",
|
||||||
"admin_settings_field_panel_api_key_label": "API-ключ Remnawave",
|
"admin_settings_field_panel_api_key_label": "API-ключ Remnawave",
|
||||||
"admin_settings_field_panel_api_key_description": "Секретный ключ API панели.",
|
"admin_settings_field_panel_api_key_description": "Секретный ключ API панели.",
|
||||||
|
"admin_settings_field_panel_api_total_timeout_seconds_label": "Общий таймаут API Remnawave",
|
||||||
|
"admin_settings_field_panel_api_total_timeout_seconds_description": "Максимальное время одного запроса к Remnawave API, в секундах.",
|
||||||
|
"admin_settings_field_panel_api_connect_timeout_seconds_label": "Таймаут подключения API Remnawave",
|
||||||
|
"admin_settings_field_panel_api_connect_timeout_seconds_description": "Максимальное время получения или открытия соединения с Remnawave API, в секундах.",
|
||||||
|
"admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "TCP/TLS-таймаут API Remnawave",
|
||||||
|
"admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Максимальное время TCP/TLS-подключения к Remnawave API, в секундах.",
|
||||||
|
"admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Таймаут чтения API Remnawave",
|
||||||
|
"admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Максимальное ожидание данных ответа от Remnawave API, в секундах.",
|
||||||
"admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave",
|
"admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave",
|
||||||
"admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.",
|
"admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.",
|
||||||
"admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию",
|
"admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию",
|
||||||
|
|||||||
@@ -236,10 +236,15 @@ def test_remnawave_settings_include_panel_webhook_metadata():
|
|||||||
remnawave_keys = (
|
remnawave_keys = (
|
||||||
"PANEL_API_URL",
|
"PANEL_API_URL",
|
||||||
"PANEL_API_KEY",
|
"PANEL_API_KEY",
|
||||||
|
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||||
|
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||||
"PANEL_WEBHOOK_SECRET",
|
"PANEL_WEBHOOK_SECRET",
|
||||||
"USER_SQUAD_UUIDS",
|
"USER_SQUAD_UUIDS",
|
||||||
"USER_EXTERNAL_SQUAD_UUID",
|
"USER_EXTERNAL_SQUAD_UUID",
|
||||||
)
|
)
|
||||||
|
timeout_keys = remnawave_keys[2:6]
|
||||||
|
|
||||||
assert field["webhook_path"] == "/webhook/panel"
|
assert field["webhook_path"] == "/webhook/panel"
|
||||||
assert field["webhook_requires_base_url"] is True
|
assert field["webhook_requires_base_url"] is True
|
||||||
@@ -250,10 +255,18 @@ def test_remnawave_settings_include_panel_webhook_metadata():
|
|||||||
assert manifest[setting_key]["section_order"] == 3
|
assert manifest[setting_key]["section_order"] == 3
|
||||||
assert manifest[setting_key]["subsection"] is None
|
assert manifest[setting_key]["subsection"] is None
|
||||||
|
|
||||||
|
for setting_key in timeout_keys:
|
||||||
|
assert manifest[setting_key]["type"] == "float"
|
||||||
|
assert manifest[setting_key]["optional"] is False
|
||||||
|
assert manifest[setting_key]["min"] == 1
|
||||||
|
|
||||||
for language in ("ru", "en"):
|
for language in ("ru", "en"):
|
||||||
messages = _locale(language)
|
messages = _locale(language)
|
||||||
assert "admin_settings_section_remnawave" in messages
|
assert "admin_settings_section_remnawave" in messages
|
||||||
assert field["webhook_hint_i18n_key"] in messages
|
assert field["webhook_hint_i18n_key"] in messages
|
||||||
|
for setting_key in timeout_keys:
|
||||||
|
assert manifest[setting_key]["i18n_label_key"] in messages
|
||||||
|
assert manifest[setting_key]["i18n_description_key"] in messages
|
||||||
|
|
||||||
|
|
||||||
def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
|
def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +19,68 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_client_timeout_uses_panel_settings(self):
|
||||||
|
service = PanelApiService(
|
||||||
|
SimpleNamespace(
|
||||||
|
PANEL_API_URL="https://panel.example.test/api",
|
||||||
|
PANEL_API_KEY="panel-key",
|
||||||
|
PANEL_API_TOTAL_TIMEOUT_SECONDS="30",
|
||||||
|
PANEL_API_CONNECT_TIMEOUT_SECONDS="10",
|
||||||
|
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS="9",
|
||||||
|
PANEL_API_SOCK_READ_TIMEOUT_SECONDS="20",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = service._client_timeout()
|
||||||
|
|
||||||
|
self.assertEqual(timeout.total, 30)
|
||||||
|
self.assertEqual(timeout.connect, 10)
|
||||||
|
self.assertEqual(timeout.sock_connect, 9)
|
||||||
|
self.assertEqual(timeout.sock_read, 20)
|
||||||
|
|
||||||
|
async def test_get_request_retries_connection_timeout(self):
|
||||||
|
service = self._make_service()
|
||||||
|
request_calls = 0
|
||||||
|
|
||||||
|
class OkResponse:
|
||||||
|
status = 200
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def text(self):
|
||||||
|
return '{"response": {"ok": true}}'
|
||||||
|
|
||||||
|
def fake_request(*_args, **_kwargs):
|
||||||
|
nonlocal request_calls
|
||||||
|
request_calls += 1
|
||||||
|
if request_calls == 1:
|
||||||
|
raise aiohttp.ConnectionTimeoutError("connect took too long")
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
service._get_session = AsyncMock(return_value=SimpleNamespace(request=fake_request))
|
||||||
|
|
||||||
|
with patch("bot.services.panel_api_service.asyncio.sleep", new=AsyncMock()):
|
||||||
|
result = await service._request("GET", "/internal-squads")
|
||||||
|
|
||||||
|
self.assertEqual(result, {"response": {"ok": True}})
|
||||||
|
self.assertEqual(request_calls, 2)
|
||||||
|
|
||||||
|
async def test_get_internal_squads_uses_stale_cache_after_refresh_failure(self):
|
||||||
|
service = self._make_service()
|
||||||
|
stale_squads = [{"uuid": "squad-1", "name": "Squad 1"}]
|
||||||
|
service._squads_cache._data["list"] = (time.monotonic() - 1, stale_squads)
|
||||||
|
service._get_internal_squads_uncached = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
squads = await service.get_internal_squads()
|
||||||
|
|
||||||
|
self.assertEqual(squads, stale_squads)
|
||||||
|
service._get_internal_squads_uncached.assert_awaited_once()
|
||||||
|
|
||||||
async def test_update_user_details_does_not_log_full_response_by_default(self):
|
async def test_update_user_details_does_not_log_full_response_by_default(self):
|
||||||
service = self._make_service()
|
service = self._make_service()
|
||||||
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
|
class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_get_stale_returns_expired_cacheable_value(self):
|
||||||
|
cache = AsyncTTLCache(ttl_seconds=60)
|
||||||
|
value = {"ok": True}
|
||||||
|
cache._data["same"] = (time.monotonic() - 1, value)
|
||||||
|
|
||||||
|
self.assertIsNone(cache.get_fresh("same"))
|
||||||
|
self.assertEqual(cache.get_stale("same"), value)
|
||||||
|
|
||||||
async def test_invalidate_remote_deletes_single_redis_key(self):
|
async def test_invalidate_remote_deletes_single_redis_key(self):
|
||||||
settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test")
|
settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test")
|
||||||
cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench")
|
cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench")
|
||||||
|
|||||||
Reference in New Issue
Block a user