Skip to content

Commit cd7bd90

Browse files
committed
refactor: Add UserLockedOutError exception and handle lockout in Mellophone client
1 parent 04ca170 commit cd7bd90

6 files changed

Lines changed: 365 additions & 201 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mellophone-valve"
3-
version = "3.2.0"
3+
version = "3.2.1"
44
description = "Python-клиент для Mellophone"
55
readme = "README.md"
66
requires-python = ">=3.7"

src/mellophone/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ServerError,
1414
TransportError,
1515
UnauthorizedError,
16+
UserLockedOutError,
1617
)
1718

1819
__all__ = [
@@ -22,6 +23,7 @@
2223
"BadRequestError",
2324
"UnauthorizedError",
2425
"ForbiddenError",
26+
"UserLockedOutError",
2527
"NotFoundError",
2628
"ServerError",
2729
"AsyncClientUnavailableError",

src/mellophone/client.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45
import warnings
56
import xml.etree.ElementTree as ET
67
from http import HTTPStatus
@@ -20,24 +21,30 @@
2021
ServerError,
2122
TransportError,
2223
UnauthorizedError,
24+
UserLockedOutError,
2325
)
2426
from .structures import RequestArgs, RequestParams
2527
from .utils import user_to_xml, xml_to_json
2628

2729
try:
2830
import httpx
2931
except ImportError: # pragma: no cover - depends on installed extra
30-
httpx = None # type: ignore[assignment]
32+
httpx = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
3133

3234
try:
3335
import requests
3436
except ImportError: # pragma: no cover - depends on installed extra
35-
requests = None # type: ignore[assignment]
37+
requests = None # type: ignore[assignment] # ty:ignore[invalid-assignment]
3638

3739

3840
class Mellophone:
3941
"""Единый клиент Mellophone с синхронными и асинхронными методами."""
4042

43+
_LOCKOUT_RE = re.compile(
44+
r"locked out for too many unsuccessful login attempts\.\s*Time to unlock:\s*(\d+)\s*s\.",
45+
re.IGNORECASE,
46+
)
47+
4148
base_url: str
4249
token_set_settings: Optional[str]
4350
token_user_manage: Optional[str]
@@ -128,6 +135,13 @@ def _build_url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
128135
clean = {k: v for k, v in params.items() if v is not None}
129136
return f"{self.base_url}{path}?{urlencode(clean)}"
130137

138+
@staticmethod
139+
def _match_lockout_error(response_text: str) -> Optional[int]:
140+
match = Mellophone._LOCKOUT_RE.search(response_text)
141+
if match is None:
142+
return None
143+
return int(match.group(1))
144+
131145
@staticmethod
132146
def _raise_for_status(response: Any) -> None:
133147
"""Выбрасывает доменное исключение для неуспешных HTTP-статусов."""
@@ -139,6 +153,9 @@ def _raise_for_status(response: Any) -> None:
139153
if status_code == HTTPStatus.UNAUTHORIZED:
140154
raise UnauthorizedError(status_code, response_text)
141155
if status_code == HTTPStatus.FORBIDDEN:
156+
unlock_in_seconds = Mellophone._match_lockout_error(response_text)
157+
if unlock_in_seconds is not None:
158+
raise UserLockedOutError(status_code, response_text, unlock_in_seconds)
142159
raise ForbiddenError(status_code, response_text)
143160
if status_code == HTTPStatus.NOT_FOUND:
144161
raise NotFoundError(status_code, response_text)

src/mellophone/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class ForbiddenError(HttpError):
2222
"""Raised for HTTP 403."""
2323

2424

25+
class UserLockedOutError(ForbiddenError):
26+
"""Raised when API reports user lockout after too many failed login attempts."""
27+
28+
def __init__(self, status_code: int, response_text: str, unlock_in_seconds: int) -> None:
29+
self.unlock_in_seconds = unlock_in_seconds
30+
super().__init__(status_code, response_text)
31+
32+
2533
class NotFoundError(HttpError):
2634
"""Raised for HTTP 404."""
2735

@@ -55,6 +63,7 @@ class MissingTokenError(ValueError):
5563
"BadRequestError",
5664
"UnauthorizedError",
5765
"ForbiddenError",
66+
"UserLockedOutError",
5867
"NotFoundError",
5968
"ServerError",
6069
"AsyncClientUnavailableError",

tests/unit/test_client_common_methods.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ def test_bad_request_maps_to_domain_error(mode_and_mock, response_factory):
7777
assert exc.value.status_code == 400
7878

7979

80+
def test_user_lockout_maps_to_specific_domain_error(mode_and_mock, response_factory):
81+
mode, install = mode_and_mock
82+
install(
83+
response_factory(
84+
403,
85+
"User expertise@curs.ru is locked out for too many unsuccessful login attempts. Time to unlock: 305 s.",
86+
)
87+
)
88+
89+
client = mellophone.Mellophone("http://example.com")
90+
with pytest.raises(mellophone.UserLockedOutError) as exc:
91+
_call(client, mode, "login", "john", "secret", ses_id="ses-1")
92+
93+
assert exc.value.status_code == 403
94+
assert exc.value.unlock_in_seconds == 305
95+
96+
8097
def test_server_error_maps_to_domain_error(mode_and_mock, response_factory):
8198
mode, install = mode_and_mock
8299
install(response_factory(500, "server error"))

0 commit comments

Comments
 (0)