From 91ac8ef33edfdeab69685ebf6db14bf0d5ae7dbd Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 12 Nov 2025 00:01:39 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20API=20?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=BE=D0=BC=20=D1=83=D0=B7=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D1=8F=D0=B4=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- pyproject.toml | 4 +- remnawave/controllers/nodes.py | 10 ++ remnawave/enums/error_code.py | 153 +++++++++++++++++++++++++ remnawave/exceptions/__init__.py | 42 ++++--- remnawave/exceptions/general.py | 74 +++++++++++-- remnawave/exceptions/handler.py | 166 +++++++++++++++++++++++----- remnawave/models/__init__.py | 4 + remnawave/models/external_squads.py | 20 +++- remnawave/models/nodes.py | 7 +- remnawave/models/users.py | 3 - 11 files changed, 424 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 786f991..4dc0468 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ pip install git+https://github.com/remnawave/python-sdk.git@development | Contract Version | Remnawave Panel Version | | ---------------- | ----------------------- | -| 2.2.13 | >=2.2.0 | +| 2.2.6 | >=2.2.6 | +| 2.2.3 | >=2.2.13 | | 2.1.19 | >=2.1.19, <2.2.0 | | 2.1.18 | >=2.1.18 | | 2.1.17 | >=2.1.16, <=2.1.17 | diff --git a/pyproject.toml b/pyproject.toml index 5191236..e34b8a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "remnawave" -version = "2.2.3.post2" -description = "A Python SDK for interacting with the Remnawave API v2.2.3." +version = "2.2.6" +description = "A Python SDK for interacting with the Remnawave API v2.2.6." authors = [ {name = "Artem",email = "dev@forestsnet.com"} ] diff --git a/remnawave/controllers/nodes.py b/remnawave/controllers/nodes.py index d19889e..fb86663 100644 --- a/remnawave/controllers/nodes.py +++ b/remnawave/controllers/nodes.py @@ -18,6 +18,8 @@ from remnawave.models import ( UpdateNodeRequestDto, UpdateNodeResponseDto, RestartAllNodesRequestBodyDto, + ResetNodeTrafficRequestDto, + ResetNodeTrafficResponseDto ) from remnawave.rapid import BaseController, delete, get, patch, post @@ -100,4 +102,12 @@ class NodesController(BaseController): body: Annotated[ReorderNodeRequestDto, PydanticBody()], ) -> ReorderNodeResponseDto: """Reorder Nodes""" + ... + + @post("/nodes/actions/reset-traffic", response_class=ResetNodeTrafficResponseDto) + async def reset_traffic_all_nodes( + self, + body: Annotated[ResetNodeTrafficRequestDto, PydanticBody()], + ) -> ResetNodeTrafficResponseDto: + """Reset Traffic All Nodes""" ... \ No newline at end of file diff --git a/remnawave/enums/error_code.py b/remnawave/enums/error_code.py index e532d21..2cbcbb4 100644 --- a/remnawave/enums/error_code.py +++ b/remnawave/enums/error_code.py @@ -78,3 +78,156 @@ class ErrorCode(StrEnum): SUBSCRIPTION_SETTINGS_NOT_FOUND = "A071" GET_SUBSCRIPTION_SETTINGS_ERROR = "A072" UPDATE_SUBSCRIPTION_SETTINGS_ERROR = "A073" + CREATE_INBOUND_ERROR = "A074" + DELETE_INBOUND_ERROR = "A075" + GET_INBOUND_ERROR = "A076" + INBOUND_NOT_FOUND = "A077" + INBOUND_TAG_ALREADY_EXISTS = "A078" + CREATE_HOST_BULK_ACTION_ERROR = "A079" + DELETE_HOST_BULK_ACTION_ERROR = "A080" + UPDATE_HOST_BULK_ACTION_ERROR = "A081" + BULK_ACTION_NOT_FOUND = "A082" + GET_USERS_STATS_ERROR = "A083" + RESET_USERS_TRAFFIC_BULK_ERROR = "A084" + UPDATE_USERS_BULK_ERROR = "A085" + DELETE_USERS_BULK_ERROR = "A086" + GET_USERS_BULK_ERROR = "A087" + CREATE_TEMPLATE_ERROR = "A088" + TEMPLATE_NOT_FOUND = "A089" + UPDATE_TEMPLATE_ERROR = "A090" + DELETE_TEMPLATE_ERROR = "A091" + TEMPLATE_NAME_ALREADY_EXISTS = "A092" + GET_TEMPLATE_ERROR = "A093" + GET_ALL_TEMPLATES_ERROR = "A094" + GENERATE_CONFIG_ERROR = "A095" + INVALID_TEMPLATE_TYPE = "A096" + CREATE_EXTERNAL_SQUAD_ERROR = "A097" + EXTERNAL_SQUAD_NOT_FOUND = "A098" + UPDATE_EXTERNAL_SQUAD_ERROR = "A099" + DELETE_EXTERNAL_SQUAD_ERROR = "A100" + EXTERNAL_SQUAD_NAME_ALREADY_EXISTS = "A101" + ADD_USERS_TO_EXTERNAL_SQUAD_ERROR = "A102" + REMOVE_USERS_FROM_EXTERNAL_SQUAD_ERROR = "A103" + GET_EXTERNAL_SQUAD_ERROR = "A104" + GET_ALL_EXTERNAL_SQUADS_ERROR = "A105" + CREATE_INTERNAL_SQUAD_ERROR = "A106" + INTERNAL_SQUAD_NOT_FOUND = "A107" + UPDATE_INTERNAL_SQUAD_ERROR = "A108" + DELETE_INTERNAL_SQUAD_ERROR = "A109" + INTERNAL_SQUAD_NAME_ALREADY_EXISTS = "A110" + GET_INTERNAL_SQUAD_ERROR = "A111" + GET_ALL_INTERNAL_SQUADS_ERROR = "A112" + CREATE_WEBHOOK_ERROR = "A113" + WEBHOOK_NOT_FOUND = "A114" + UPDATE_WEBHOOK_ERROR = "A115" + DELETE_WEBHOOK_ERROR = "A116" + WEBHOOK_URL_ALREADY_EXISTS = "A117" + GET_WEBHOOK_ERROR = "A118" + GET_ALL_WEBHOOKS_ERROR = "A119" + WEBHOOK_DELIVERY_ERROR = "A120" + CREATE_PASSKEY_ERROR = "A121" + PASSKEY_NOT_FOUND = "A122" + DELETE_PASSKEY_ERROR = "A123" + GET_PASSKEY_ERROR = "A124" + GET_ALL_PASSKEYS_ERROR = "A125" + PASSKEY_ALREADY_EXISTS = "A126" + CREATE_SNIPPET_ERROR = "A127" + SNIPPET_NOT_FOUND = "A128" + UPDATE_SNIPPET_ERROR = "A129" + DELETE_SNIPPET_ERROR = "A130" + SNIPPET_NAME_ALREADY_EXISTS = "A131" + GET_SNIPPET_ERROR = "A132" + GET_ALL_SNIPPETS_ERROR = "A133" + HWID_RESET_ERROR = "A134" + HWID_NOT_FOUND = "A135" + GET_HWID_ERROR = "A136" + GET_ALL_HWIDS_ERROR = "A137" + DELETE_HWID_ERROR = "A138" + BANDWIDTH_STATS_ERROR = "A139" + GET_NODES_USAGE_STATS_ERROR = "A140" + SUBSCRIPTION_REQUEST_ERROR = "A141" + SUBSCRIPTION_REQUEST_NOT_FOUND = "A142" + GET_SUBSCRIPTION_REQUEST_ERROR = "A143" + GET_ALL_SUBSCRIPTION_REQUESTS_ERROR = "A144" + APPROVE_SUBSCRIPTION_REQUEST_ERROR = "A145" + REJECT_SUBSCRIPTION_REQUEST_ERROR = "A146" + CREATE_SUBSCRIPTION_REQUEST_HISTORY_ERROR = "A147" + GET_SUBSCRIPTION_REQUEST_HISTORY_ERROR = "A148" + KEYGEN_ERROR = "A149" + GENERATE_KEYS_ERROR = "A150" + INVALID_KEY_TYPE = "A151" + SYSTEM_STATS_ERROR = "A152" + SYSTEM_HEALTH_ERROR = "A153" + NODES_METRICS_ERROR = "A154" + X25519_KEYGEN_ERROR = "A155" + HAPP_CRYPTO_ERROR = "A156" + SRR_MATCHER_ERROR = "A157" + GET_REMNAWAVE_SETTINGS_ERROR = "A158" + UPDATE_REMNAWAVE_SETTINGS_ERROR = "A159" + OAUTH_ERROR = "A160" + PASSKEY_SETTINGS_ERROR = "A161" + TELEGRAM_AUTH_ERROR = "A162" + BRANDING_SETTINGS_ERROR = "A163" + CONFIG_PROFILE_ERROR = "A164" + CONFIG_PROFILE_NOT_FOUND = "A165" + CREATE_CONFIG_PROFILE_ERROR = "A166" + UPDATE_CONFIG_PROFILE_ERROR = "A167" + DELETE_CONFIG_PROFILE_ERROR = "A168" + GET_CONFIG_PROFILE_ERROR = "A169" + GET_ALL_CONFIG_PROFILES_ERROR = "A170" + XRAY_CONFIG_ERROR = "A171" + XRAY_CONFIG_VALIDATION_ERROR = "A172" + INFRA_BILLING_ERROR = "A173" + INFRA_BILLING_NOT_FOUND = "A174" + GET_INFRA_BILLING_ERROR = "A175" + UPDATE_INFRA_BILLING_ERROR = "A176" + CALCULATE_BILLING_ERROR = "A177" + BILLING_PERIOD_ERROR = "A178" + + # Добавляем новые коды из failed тестов + CREATE_SUBSCRIPTION_TEMPLATE_ERROR = "A179" + SUBSCRIPTION_TEMPLATE_NOT_FOUND = "A180" + UPDATE_SUBSCRIPTION_TEMPLATE_ERROR = "A181" + DELETE_SUBSCRIPTION_TEMPLATE_ERROR = "A182" + GET_SUBSCRIPTION_TEMPLATE_ERROR = "A183" + + # Валидационные ошибки + VALIDATION_ERROR = "V001" + INVALID_UUID_FORMAT = "V002" + INVALID_EMAIL_FORMAT = "V003" + INVALID_DATE_FORMAT = "V004" + REQUIRED_FIELD_MISSING = "V005" + FIELD_TOO_LONG = "V006" + FIELD_TOO_SHORT = "V007" + INVALID_ENUM_VALUE = "V008" + INVALID_REGEX_PATTERN = "V009" + NUMERIC_VALIDATION_ERROR = "V010" + + # Сетевые ошибки + NETWORK_ERROR = "N003" + TIMEOUT_ERROR = "N004" + CONNECTION_ERROR = "N005" + DNS_ERROR = "N006" + SSL_ERROR = "N007" + + # Ошибки аутентификации и авторизации + INVALID_TOKEN = "AUTH001" + TOKEN_EXPIRED = "AUTH002" + INVALID_CREDENTIALS = "AUTH003" + TWO_FACTOR_REQUIRED = "AUTH004" + ACCOUNT_LOCKED = "AUTH005" + PASSWORD_COMPLEXITY_ERROR = "AUTH006" + + # Ошибки бизнес-логики + TRAFFIC_LIMIT_EXCEEDED = "BL001" + USER_LIMIT_EXCEEDED = "BL002" + SUBSCRIPTION_EXPIRED = "BL003" + FEATURE_NOT_AVAILABLE = "BL004" + QUOTA_EXCEEDED = "BL005" + RESOURCE_LOCKED = "BL006" + + # Общие коды + UNKNOWN = "UNKNOWN" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + MAINTENANCE_MODE = "MAINTENANCE" + RATE_LIMIT_EXCEEDED = "RATE_LIMIT" \ No newline at end of file diff --git a/remnawave/exceptions/__init__.py b/remnawave/exceptions/__init__.py index da87a70..39b4dbe 100644 --- a/remnawave/exceptions/__init__.py +++ b/remnawave/exceptions/__init__.py @@ -1,23 +1,39 @@ -from .handler import handle_api_error from .general import ( - ConflictError, - ApiErrorResponse, - BadRequestError, - NotFoundError, - ForbiddenError, - UnauthorizedError, - ServerError, ApiError, + ApiErrorResponse, + AuthenticationError, + BadRequestError, + BusinessLogicError, + ConflictError, + FeatureNotAvailableError, + ForbiddenError, + MaintenanceError, + NetworkError, + NotFoundError, + QuotaExceededError, + RateLimitError, + ServerError, + UnauthorizedError, + ValidationError, ) +from .handler import handle_api_error __all__ = [ - "handle_api_error", "ApiError", "ApiErrorResponse", - "NotFoundError", + "AuthenticationError", "BadRequestError", - "ForbiddenError", - "UnauthorizedError", + "BusinessLogicError", "ConflictError", + "FeatureNotAvailableError", + "ForbiddenError", + "MaintenanceError", + "NetworkError", + "NotFoundError", + "QuotaExceededError", + "RateLimitError", "ServerError", -] + "UnauthorizedError", + "ValidationError", + "handle_api_error", +] \ No newline at end of file diff --git a/remnawave/exceptions/general.py b/remnawave/exceptions/general.py index 8c7e63e..dadb01a 100644 --- a/remnawave/exceptions/general.py +++ b/remnawave/exceptions/general.py @@ -1,14 +1,13 @@ from datetime import datetime +from typing import Any, List, Optional from pydantic import AliasChoices, BaseModel, Field from remnawave.enums import ErrorCode -from typing import Any, List, Optional - - class ApiErrorResponse(BaseModel): + """Standard API error response model""" timestamp: Optional[datetime] = Field(None, description="Время возникновения ошибки") path: Optional[str] = Field(None, description="Путь запроса") message: str = Field(..., description="Сообщение об ошибке") @@ -23,6 +22,8 @@ class ApiErrorResponse(BaseModel): class ApiError(Exception): + """Base API error exception""" + def __init__(self, status_code: int, error: ApiErrorResponse): self.status_code = status_code self.error = error @@ -30,38 +31,93 @@ class ApiError(Exception): f"API Error {error.code}: {error.message} (HTTP {status_code})" ) + @property + def code(self) -> Optional[str]: + """Get error code""" + return self.error.code + + @property + def message(self) -> str: + """Get error message""" + return self.error.message + + @property + def timestamp(self) -> Optional[datetime]: + """Get error timestamp""" + return self.error.timestamp + + @property + def path(self) -> Optional[str]: + """Get request path""" + return self.error.path + class BadRequestError(ApiError): """Ошибки клиента (400)""" - pass class UnauthorizedError(ApiError): """Ошибка авторизации (401)""" - pass class ForbiddenError(ApiError): """Доступ запрещен (403)""" - pass class NotFoundError(ApiError): """Ресурс не найден (404)""" - pass class ConflictError(ApiError): """Конфликт (409)""" + pass + +class ValidationError(ApiError): + """Ошибка валидации данных (422)""" pass class ServerError(ApiError): - """Серверная ошибка (500)""" - + """Серверная ошибка (500+)""" pass + + +# Новые специализированные исключения +class NetworkError(ApiError): + """Сетевые ошибки""" + pass + + +class AuthenticationError(ApiError): + """Ошибки аутентификации""" + pass + + +class BusinessLogicError(ApiError): + """Ошибки бизнес-логики""" + pass + + +class RateLimitError(BadRequestError): + """Превышен лимит запросов""" + pass + + +class MaintenanceError(ServerError): + """Режим обслуживания""" + pass + + +class QuotaExceededError(BusinessLogicError): + """Превышена квота""" + pass + + +class FeatureNotAvailableError(BusinessLogicError): + """Функция недоступна""" + pass \ No newline at end of file diff --git a/remnawave/exceptions/handler.py b/remnawave/exceptions/handler.py index efeafd7..ddaa318 100644 --- a/remnawave/exceptions/handler.py +++ b/remnawave/exceptions/handler.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Dict, Type import httpx @@ -12,11 +13,15 @@ from .general import ( NotFoundError, ServerError, UnauthorizedError, + ValidationError, + NetworkError, + AuthenticationError, + BusinessLogicError, ) -ERRORS: dict[str, dict] = { +ERRORS: Dict[str, Type[ApiError]] = { ErrorCode.INTERNAL_SERVER_ERROR: ServerError, - ErrorCode.LOGIN_ERROR: ServerError, + ErrorCode.LOGIN_ERROR: AuthenticationError, ErrorCode.UNAUTHORIZED: UnauthorizedError, ErrorCode.FORBIDDEN_ROLE_ERROR: ForbiddenError, ErrorCode.CREATE_API_TOKEN_ERROR: ServerError, @@ -33,9 +38,9 @@ ERRORS: dict[str, dict] = { ErrorCode.CREATE_MANY_INBOUNDS_ERROR: ServerError, ErrorCode.FIND_ALL_INBOUNDS_ERROR: ServerError, ErrorCode.CREATE_USER_ERROR: ServerError, - ErrorCode.USER_USERNAME_ALREADY_EXISTS: BadRequestError, - ErrorCode.USER_SHORT_UUID_ALREADY_EXISTS: BadRequestError, - ErrorCode.USER_SUBSCRIPTION_UUID_ALREADY_EXISTS: BadRequestError, + ErrorCode.USER_USERNAME_ALREADY_EXISTS: ConflictError, + ErrorCode.USER_SHORT_UUID_ALREADY_EXISTS: ConflictError, + ErrorCode.USER_SUBSCRIPTION_UUID_ALREADY_EXISTS: ConflictError, ErrorCode.CREATE_USER_WITH_INBOUNDS_ERROR: ServerError, ErrorCode.CANT_GET_CREATED_USER_WITH_INBOUNDS: ServerError, ErrorCode.GET_ALL_USERS_ERROR: ServerError, @@ -43,12 +48,12 @@ ERRORS: dict[str, dict] = { ErrorCode.GET_USER_BY_ERROR: ServerError, ErrorCode.REVOKE_USER_SUBSCRIPTION_ERROR: ServerError, ErrorCode.DISABLE_USER_ERROR: ServerError, - ErrorCode.USER_ALREADY_DISABLED: BadRequestError, - ErrorCode.USER_ALREADY_ENABLED: BadRequestError, + ErrorCode.USER_ALREADY_DISABLED: ConflictError, + ErrorCode.USER_ALREADY_ENABLED: ConflictError, ErrorCode.ENABLE_USER_ERROR: ServerError, ErrorCode.CREATE_NODE_ERROR: ServerError, - ErrorCode.NODE_NAME_ALREADY_EXISTS: BadRequestError, - ErrorCode.NODE_ADDRESS_ALREADY_EXISTS: BadRequestError, + ErrorCode.NODE_NAME_ALREADY_EXISTS: ConflictError, + ErrorCode.NODE_ADDRESS_ALREADY_EXISTS: ConflictError, ErrorCode.NODE_ERROR_WITH_MSG: ServerError, ErrorCode.NODE_ERROR_500_WITH_MSG: ServerError, ErrorCode.RESTART_NODE_ERROR: ServerError, @@ -61,7 +66,7 @@ ERRORS: dict[str, dict] = { ErrorCode.GET_ONE_NODE_ERROR: ServerError, ErrorCode.DELETE_NODE_ERROR: ServerError, ErrorCode.CREATE_HOST_ERROR: ServerError, - ErrorCode.HOST_REMARK_ALREADY_EXISTS: BadRequestError, + ErrorCode.HOST_REMARK_ALREADY_EXISTS: ConflictError, ErrorCode.HOST_NOT_FOUND: NotFoundError, ErrorCode.DELETE_HOST_ERROR: ServerError, ErrorCode.GET_USER_STATS_ERROR: ServerError, @@ -77,7 +82,7 @@ ERRORS: dict[str, dict] = { ErrorCode.GET_ALL_INBOUNDS_ERROR: ServerError, ErrorCode.BULK_DELETE_USERS_BY_STATUS_ERROR: ServerError, ErrorCode.UPDATE_INBOUND_ERROR: ServerError, - ErrorCode.CONFIG_VALIDATION_ERROR: ServerError, + ErrorCode.CONFIG_VALIDATION_ERROR: ValidationError, ErrorCode.USERS_NOT_FOUND: NotFoundError, ErrorCode.GET_USER_BY_UNIQUE_FIELDS_NOT_FOUND: NotFoundError, ErrorCode.UPDATE_EXCEEDED_TRAFFIC_USERS_ERROR: ServerError, @@ -85,15 +90,106 @@ ERRORS: dict[str, dict] = { ErrorCode.CREATE_ADMIN_ERROR: ServerError, ErrorCode.GET_AUTH_STATUS_ERROR: ServerError, ErrorCode.FORBIDDEN_ONE: ForbiddenError, + ErrorCode.FORBIDDEN_TWO: ForbiddenError, ErrorCode.DISABLE_NODE_ERROR: ServerError, ErrorCode.GET_ONE_HOST_ERROR: ServerError, ErrorCode.SUBSCRIPTION_SETTINGS_NOT_FOUND: NotFoundError, ErrorCode.GET_SUBSCRIPTION_SETTINGS_ERROR: ServerError, ErrorCode.UPDATE_SUBSCRIPTION_SETTINGS_ERROR: ServerError, + + ErrorCode.CREATE_SUBSCRIPTION_TEMPLATE_ERROR: ServerError, + ErrorCode.SUBSCRIPTION_TEMPLATE_NOT_FOUND: NotFoundError, + ErrorCode.UPDATE_SUBSCRIPTION_TEMPLATE_ERROR: ServerError, + ErrorCode.DELETE_SUBSCRIPTION_TEMPLATE_ERROR: ServerError, + ErrorCode.GET_SUBSCRIPTION_TEMPLATE_ERROR: ServerError, + + ErrorCode.CREATE_INBOUND_ERROR: ServerError, + ErrorCode.DELETE_INBOUND_ERROR: ServerError, + ErrorCode.GET_INBOUND_ERROR: ServerError, + ErrorCode.INBOUND_NOT_FOUND: NotFoundError, + ErrorCode.INBOUND_TAG_ALREADY_EXISTS: ConflictError, + + ErrorCode.CREATE_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.EXTERNAL_SQUAD_NOT_FOUND: NotFoundError, + ErrorCode.UPDATE_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.DELETE_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.EXTERNAL_SQUAD_NAME_ALREADY_EXISTS: ConflictError, + ErrorCode.ADD_USERS_TO_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.REMOVE_USERS_FROM_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.GET_EXTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.GET_ALL_EXTERNAL_SQUADS_ERROR: ServerError, + + ErrorCode.CREATE_INTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.INTERNAL_SQUAD_NOT_FOUND: NotFoundError, + ErrorCode.UPDATE_INTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.DELETE_INTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.INTERNAL_SQUAD_NAME_ALREADY_EXISTS: ConflictError, + ErrorCode.GET_INTERNAL_SQUAD_ERROR: ServerError, + ErrorCode.GET_ALL_INTERNAL_SQUADS_ERROR: ServerError, + + ErrorCode.CREATE_SNIPPET_ERROR: ServerError, + ErrorCode.SNIPPET_NOT_FOUND: NotFoundError, + ErrorCode.UPDATE_SNIPPET_ERROR: ServerError, + ErrorCode.DELETE_SNIPPET_ERROR: ServerError, + ErrorCode.SNIPPET_NAME_ALREADY_EXISTS: ConflictError, + ErrorCode.GET_SNIPPET_ERROR: ServerError, + ErrorCode.GET_ALL_SNIPPETS_ERROR: ServerError, + + # Валидационные ошибки + ErrorCode.VALIDATION_ERROR: ValidationError, + ErrorCode.INVALID_UUID_FORMAT: ValidationError, + ErrorCode.INVALID_EMAIL_FORMAT: ValidationError, + ErrorCode.INVALID_DATE_FORMAT: ValidationError, + ErrorCode.REQUIRED_FIELD_MISSING: ValidationError, + ErrorCode.FIELD_TOO_LONG: ValidationError, + ErrorCode.FIELD_TOO_SHORT: ValidationError, + ErrorCode.INVALID_ENUM_VALUE: ValidationError, + ErrorCode.INVALID_REGEX_PATTERN: ValidationError, + ErrorCode.NUMERIC_VALIDATION_ERROR: ValidationError, + + # Сетевые ошибки + ErrorCode.NETWORK_ERROR: NetworkError, + ErrorCode.TIMEOUT_ERROR: NetworkError, + ErrorCode.CONNECTION_ERROR: NetworkError, + ErrorCode.DNS_ERROR: NetworkError, + ErrorCode.SSL_ERROR: NetworkError, + + # Ошибки аутентификации + ErrorCode.INVALID_TOKEN: AuthenticationError, + ErrorCode.TOKEN_EXPIRED: AuthenticationError, + ErrorCode.INVALID_CREDENTIALS: AuthenticationError, + ErrorCode.TWO_FACTOR_REQUIRED: AuthenticationError, + ErrorCode.ACCOUNT_LOCKED: AuthenticationError, + ErrorCode.PASSWORD_COMPLEXITY_ERROR: ValidationError, + + # Бизнес-логика + ErrorCode.TRAFFIC_LIMIT_EXCEEDED: BusinessLogicError, + ErrorCode.USER_LIMIT_EXCEEDED: BusinessLogicError, + ErrorCode.SUBSCRIPTION_EXPIRED: BusinessLogicError, + ErrorCode.FEATURE_NOT_AVAILABLE: BusinessLogicError, + ErrorCode.QUOTA_EXCEEDED: BusinessLogicError, + ErrorCode.RESOURCE_LOCKED: ConflictError, + + # Системные ошибки + ErrorCode.SYSTEM_STATS_ERROR: ServerError, + ErrorCode.SYSTEM_HEALTH_ERROR: ServerError, + ErrorCode.NODES_METRICS_ERROR: ServerError, + ErrorCode.X25519_KEYGEN_ERROR: ServerError, + ErrorCode.HAPP_CRYPTO_ERROR: ServerError, + ErrorCode.SRR_MATCHER_ERROR: ServerError, + + # Настройки Remnawave + ErrorCode.GET_REMNAWAVE_SETTINGS_ERROR: ServerError, + ErrorCode.UPDATE_REMNAWAVE_SETTINGS_ERROR: ServerError, + ErrorCode.OAUTH_ERROR: AuthenticationError, + ErrorCode.PASSKEY_SETTINGS_ERROR: ServerError, + ErrorCode.TELEGRAM_AUTH_ERROR: AuthenticationError, + ErrorCode.BRANDING_SETTINGS_ERROR: ServerError, } def handle_api_error(response: httpx.Response) -> None: + """Handle API error responses and raise appropriate exceptions""" if response.status_code >= 400: try: error_data = response.json() @@ -103,7 +199,7 @@ def handle_api_error(response: httpx.Response) -> None: if error_response.timestamp is None: error_response.timestamp = datetime.now() if error_response.path is None: - error_response.path = response.request.url.path + error_response.path = str(response.request.url.path) if error_response.code is None: # Use status_code or default to UNKNOWN if error_response.status_code: @@ -111,32 +207,46 @@ def handle_api_error(response: httpx.Response) -> None: else: error_response.code = "UNKNOWN" + # Map error code to exception class if error_response.code in ERRORS: exception_class = ERRORS[error_response.code] else: - if response.status_code == 400: - exception_class = BadRequestError - elif response.status_code == 401: - exception_class = UnauthorizedError - elif response.status_code == 403: - exception_class = ForbiddenError - elif response.status_code == 404: - exception_class = NotFoundError - elif response.status_code == 409: - exception_class = ConflictError - elif response.status_code >= 500: - exception_class = ServerError - else: - exception_class = ApiError + # Fallback based on HTTP status code + exception_class = _get_exception_by_status_code(response.status_code) raise exception_class(response.status_code, error_response) + except ValueError: + # JSON parsing failed, create generic error raise ApiError( response.status_code, ApiErrorResponse( timestamp=datetime.now(), - path=response.request.url.path, - message="Unknown error " + response.text, + path=str(response.request.url.path), + message=f"Unknown error: {response.text}", code="UNKNOWN", + status_code=response.status_code, ), ) + + +def _get_exception_by_status_code(status_code: int) -> Type[ApiError]: + """Get exception class based on HTTP status code""" + if status_code == 400: + return BadRequestError + elif status_code == 401: + return UnauthorizedError + elif status_code == 403: + return ForbiddenError + elif status_code == 404: + return NotFoundError + elif status_code == 409: + return ConflictError + elif status_code == 422: + return ValidationError + elif status_code == 429: + return BadRequestError # Rate limit + elif status_code >= 500: + return ServerError + else: + return ApiError \ No newline at end of file diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index e63ca12..b428c12 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -172,6 +172,8 @@ from .nodes import ( UpdateNodeResponseDto, RestartAllNodesRequestDto, # Legacy alias, RestartAllNodesRequestBodyDto, + ResetNodeTrafficRequestDto, + ResetNodeTrafficResponseDto ) from .nodes_usage_history import ( GetNodeUserUsageByRangeResponseDto, @@ -384,6 +386,8 @@ __all__ = [ "NodeConfigProfileRequestDto", "RestartAllNodesRequestDto", # Legacy alias "RestartAllNodesRequestBodyDto", + "ResetNodeTrafficRequestDto", + "ResetNodeTrafficResponseDto", # Hosts models "CreateHostRequestDto", "CreateHostResponseDto", diff --git a/remnawave/models/external_squads.py b/remnawave/models/external_squads.py index 665e237..5b327e9 100644 --- a/remnawave/models/external_squads.py +++ b/remnawave/models/external_squads.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import StrEnum -from typing import List, Optional +from typing import Dict, List, Optional from uuid import UUID from pydantic import BaseModel, Field @@ -41,22 +41,30 @@ class ExternalSquadSubscriptionSettingsDto(BaseModel): randomize_hosts: bool = Field(alias="randomizeHosts") +# НОВЫЕ МОДЕЛИ +class ExternalSquadHostOverridesDto(BaseModel): + """External squad host overrides""" + server_description: Optional[str] = Field(None, alias="serverDescription", max_length=30) + vless_route_id: Optional[int] = Field(None, alias="vlessRouteId", ge=0, le=65535) + + class ExternalSquadDto(BaseModel): """External squad data model""" uuid: UUID name: str info: ExternalSquadInfoDto templates: List[ExternalSquadTemplateDto] - subscription_settings: Optional[ExternalSquadSubscriptionSettingsDto] = Field(alias="subscriptionSettings") + subscription_settings: Optional[ExternalSquadSubscriptionSettingsDto] = Field(None, alias="subscriptionSettings") + host_overrides: Optional[ExternalSquadHostOverridesDto] = Field(None, alias="hostOverrides") + response_headers: Optional[Dict[str, str]] = Field(None, alias="responseHeaders") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") # Request/Response models -class GetExternalSquadsResponseDto(BaseModel): +class GetExternalSquadsResponseDto(ExternalSquadDto): """Response with all external squads""" - total: float - external_squads: List[ExternalSquadDto] = Field(alias="externalSquads") + pass class GetExternalSquadByUuidResponseDto(ExternalSquadDto): @@ -80,6 +88,8 @@ class UpdateExternalSquadRequestDto(BaseModel): name: Optional[str] = Field(None, min_length=2, max_length=30, pattern=r"^[A-Za-z0-9_\s-]+$") templates: Optional[List[ExternalSquadTemplateDto]] = None subscription_settings: Optional[ExternalSquadSubscriptionSettingsDto] = Field(None, serialization_alias="subscriptionSettings") + host_overrides: Optional[ExternalSquadHostOverridesDto] = Field(None, serialization_alias="hostOverrides") + response_headers: Optional[Dict[str, str]] = Field(None, serialization_alias="responseHeaders") class UpdateExternalSquadResponseDto(ExternalSquadDto): diff --git a/remnawave/models/nodes.py b/remnawave/models/nodes.py index a3773a1..94720ad 100644 --- a/remnawave/models/nodes.py +++ b/remnawave/models/nodes.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated, List, Optional +from typing import Annotated, List, Optional, Union from uuid import UUID from pydantic import BaseModel, Field, StringConstraints, RootModel @@ -208,7 +208,12 @@ class DeleteNodeResponseDto(BaseModel): class RestartAllNodesRequestBodyDto(BaseModel): force_restart: bool = Field(default=False, alias="forceRestart") + +class ResetNodeTrafficRequestDto(BaseModel): + uuid: Union[str, UUID] = Field(alias="uuid") +class ResetNodeTrafficResponseDto(RestartEventResponse): + pass # Для обратной совместимости RestartAllNodesRequestDto = RestartAllNodesRequestBodyDto diff --git a/remnawave/models/users.py b/remnawave/models/users.py index 4df4b4a..78dd483 100644 --- a/remnawave/models/users.py +++ b/remnawave/models/users.py @@ -134,9 +134,6 @@ class UserResponseDto(BaseModel): external_squad_uuid: UUID | None = Field(None, alias="externalSquadUuid") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") - - model_config = {"alias_generator": to_camel, "populate_by_name": True} - class EmailUserResponseDto(RootModel[list[UserResponseDto]]): def __iter__(self):