lixinger-python 0.3.12__py3-none-any.whl → 0.3.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lixinger/api/base.py +68 -5
- lixinger/exceptions.py +17 -1
- lixinger/utils/retry.py +34 -2
- {lixinger_python-0.3.12.dist-info → lixinger_python-0.3.14.dist-info}/METADATA +1 -1
- {lixinger_python-0.3.12.dist-info → lixinger_python-0.3.14.dist-info}/RECORD +7 -7
- {lixinger_python-0.3.12.dist-info → lixinger_python-0.3.14.dist-info}/WHEEL +0 -0
- {lixinger_python-0.3.12.dist-info → lixinger_python-0.3.14.dist-info}/licenses/LICENSE +0 -0
lixinger/api/base.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
from email.utils import parsedate_to_datetime
|
|
1
3
|
from typing import Any
|
|
2
4
|
|
|
3
5
|
import httpx
|
|
4
6
|
|
|
5
7
|
from lixinger.config import Config, settings
|
|
6
|
-
from lixinger.exceptions import APIError, AuthenticationError, RateLimitError
|
|
8
|
+
from lixinger.exceptions import APIError, AuthenticationError, RateLimitError, ValidationError
|
|
7
9
|
from lixinger.utils.rate_limiter import AsyncRateLimiter
|
|
8
10
|
from lixinger.utils.retry import async_retry_on_failure
|
|
9
11
|
|
|
@@ -13,6 +15,12 @@ _global_rate_limiter = AsyncRateLimiter(max_requests=1000)
|
|
|
13
15
|
# Lazy initialization of global http client to avoid issues with proxy/config changes
|
|
14
16
|
_global_http_client: httpx.AsyncClient | None = None
|
|
15
17
|
|
|
18
|
+
# Fallback delay in seconds when a 429 response omits Retry-After. The Lixinger
|
|
19
|
+
# rate-limit window is 60s, so short exponential backoff (1/2/4s) almost always
|
|
20
|
+
# fires inside the same window and gets 429ed again. 15s gives the window room
|
|
21
|
+
# to age and burns fewer retry attempts on doomed requests.
|
|
22
|
+
DEFAULT_RATE_LIMIT_RETRY_AFTER = 15.0
|
|
23
|
+
|
|
16
24
|
|
|
17
25
|
def get_global_client() -> httpx.AsyncClient:
|
|
18
26
|
"""Get or create the global HTTP client."""
|
|
@@ -29,6 +37,39 @@ def get_global_client() -> httpx.AsyncClient:
|
|
|
29
37
|
return _global_http_client
|
|
30
38
|
|
|
31
39
|
|
|
40
|
+
def _parse_retry_after(header_value: str | None) -> float | None:
|
|
41
|
+
"""Parse a Retry-After header into seconds.
|
|
42
|
+
|
|
43
|
+
Accepts both formats defined by RFC 7231 §7.1.3:
|
|
44
|
+
- ``delta-seconds`` (e.g. ``"120"``)
|
|
45
|
+
- ``HTTP-date`` (e.g. ``"Wed, 21 Oct 2015 07:28:00 GMT"``)
|
|
46
|
+
|
|
47
|
+
Returns ``None`` when the header is missing or unparseable so the caller
|
|
48
|
+
can fall back to its default backoff.
|
|
49
|
+
"""
|
|
50
|
+
if not header_value:
|
|
51
|
+
return None
|
|
52
|
+
value = header_value.strip()
|
|
53
|
+
# Try delta-seconds first — the common case.
|
|
54
|
+
try:
|
|
55
|
+
seconds = float(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
return seconds if seconds >= 0 else None
|
|
60
|
+
# Fall back to HTTP-date.
|
|
61
|
+
try:
|
|
62
|
+
target = parsedate_to_datetime(value)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
if target.tzinfo is None:
|
|
66
|
+
# RFC 7231 HTTP-date is always GMT, so treat naive dates as UTC.
|
|
67
|
+
target = target.replace(tzinfo=UTC)
|
|
68
|
+
now = datetime.now(tz=target.tzinfo)
|
|
69
|
+
delta = (target - now).total_seconds()
|
|
70
|
+
return max(delta, 0.0)
|
|
71
|
+
|
|
72
|
+
|
|
32
73
|
class BaseAPI:
|
|
33
74
|
"""Base class for API endpoint groups."""
|
|
34
75
|
|
|
@@ -42,7 +83,12 @@ class BaseAPI:
|
|
|
42
83
|
self._config = config or settings
|
|
43
84
|
self._rate_limiter = rate_limiter or _global_rate_limiter
|
|
44
85
|
|
|
45
|
-
@async_retry_on_failure(
|
|
86
|
+
@async_retry_on_failure(
|
|
87
|
+
max_retries=3,
|
|
88
|
+
backoff=1.0,
|
|
89
|
+
# Do NOT retry non-transient errors even though they inherit from Exception.
|
|
90
|
+
no_retry_on=(AuthenticationError, ValidationError),
|
|
91
|
+
)
|
|
46
92
|
async def _request(
|
|
47
93
|
self,
|
|
48
94
|
method: str,
|
|
@@ -72,7 +118,16 @@ class BaseAPI:
|
|
|
72
118
|
)
|
|
73
119
|
|
|
74
120
|
if response.status_code == 429:
|
|
75
|
-
|
|
121
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
122
|
+
# Server didn't tell us how long to wait — pick a delay long enough
|
|
123
|
+
# for the 60s rate-limit window to actually age.
|
|
124
|
+
if retry_after is None:
|
|
125
|
+
retry_after = DEFAULT_RATE_LIMIT_RETRY_AFTER
|
|
126
|
+
raise RateLimitError(
|
|
127
|
+
"Rate limit exceeded",
|
|
128
|
+
status_code=429,
|
|
129
|
+
retry_after=retry_after,
|
|
130
|
+
)
|
|
76
131
|
if response.status_code == 401:
|
|
77
132
|
raise AuthenticationError("Authentication failed", status_code=401)
|
|
78
133
|
if response.status_code != 200:
|
|
@@ -88,7 +143,15 @@ class BaseAPI:
|
|
|
88
143
|
code = data.get("code")
|
|
89
144
|
message = data.get("message")
|
|
90
145
|
|
|
91
|
-
|
|
146
|
+
# code is the machine-readable success signal; message is human text.
|
|
147
|
+
# Lixinger returns code=1 for success even when there's no data
|
|
148
|
+
# (e.g. message="no data" on non-trading days or empty ranges) —
|
|
149
|
+
# treat any code=1 as success and normalize a missing/null payload
|
|
150
|
+
# to [] so downstream iteration and get_response_df both work.
|
|
151
|
+
if code != 1:
|
|
92
152
|
raise APIError(f"API returned error code {code}: {message}")
|
|
93
153
|
|
|
94
|
-
|
|
154
|
+
payload = data.get("data")
|
|
155
|
+
if payload is None:
|
|
156
|
+
return []
|
|
157
|
+
return payload
|
lixinger/exceptions.py
CHANGED
|
@@ -11,7 +11,23 @@ class APIError(LixingerError):
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class RateLimitError(APIError):
|
|
14
|
-
"""Rate limit exceeded (429).
|
|
14
|
+
"""Rate limit exceeded (429).
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
retry_after: Optional server-provided delay in seconds parsed from the
|
|
18
|
+
``Retry-After`` response header. ``None`` when the header was absent
|
|
19
|
+
or malformed. The retry decorator honors this value in place of the
|
|
20
|
+
default exponential backoff.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
status_code: int | None = None,
|
|
27
|
+
retry_after: float | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message, status_code=status_code)
|
|
30
|
+
self.retry_after = retry_after
|
|
15
31
|
|
|
16
32
|
|
|
17
33
|
class AuthenticationError(APIError):
|
lixinger/utils/retry.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections.abc import Awaitable, Callable
|
|
3
3
|
from functools import wraps
|
|
4
|
+
import random
|
|
4
5
|
from typing import Any, TypeVar
|
|
5
6
|
|
|
6
7
|
T = TypeVar("T")
|
|
@@ -10,8 +11,28 @@ def async_retry_on_failure(
|
|
|
10
11
|
max_retries: int = 3,
|
|
11
12
|
backoff: float = 1.0,
|
|
12
13
|
retry_on: tuple[type[Exception], ...] = (Exception,),
|
|
14
|
+
no_retry_on: tuple[type[Exception], ...] = (),
|
|
15
|
+
jitter: float = 0.2,
|
|
13
16
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
14
|
-
"""Retry failed async requests with exponential backoff.
|
|
17
|
+
"""Retry failed async requests with exponential backoff and jitter.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_retries: Maximum number of retry attempts (total attempts = max_retries + 1).
|
|
21
|
+
backoff: Base delay in seconds for exponential backoff (2**attempt * backoff).
|
|
22
|
+
retry_on: Exception types that trigger a retry.
|
|
23
|
+
no_retry_on: Exception types that must NOT be retried even if matched by
|
|
24
|
+
``retry_on``. Use this to short-circuit on non-transient errors such
|
|
25
|
+
as authentication failures.
|
|
26
|
+
jitter: Multiplicative jitter fraction applied to every sleep. The actual
|
|
27
|
+
sleep is drawn from ``uniform(base * (1 - jitter), base * (1 + jitter))``
|
|
28
|
+
where ``base`` is either the ``retry_after`` hint or the exponential
|
|
29
|
+
backoff. Pass ``0`` to disable (mostly useful in tests). Values are
|
|
30
|
+
clamped so the sleep never goes negative.
|
|
31
|
+
|
|
32
|
+
If the caught exception exposes a ``retry_after`` attribute (float seconds,
|
|
33
|
+
typically parsed from a ``Retry-After`` HTTP header on 429 responses), that
|
|
34
|
+
value is used for the next sleep instead of the exponential backoff.
|
|
35
|
+
"""
|
|
15
36
|
|
|
16
37
|
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
17
38
|
@wraps(func)
|
|
@@ -20,9 +41,20 @@ def async_retry_on_failure(
|
|
|
20
41
|
try:
|
|
21
42
|
return await func(*args, **kwargs)
|
|
22
43
|
except retry_on as e:
|
|
44
|
+
# Bail out immediately on non-transient errors.
|
|
45
|
+
if no_retry_on and isinstance(e, no_retry_on):
|
|
46
|
+
raise
|
|
23
47
|
if attempt == max_retries:
|
|
24
48
|
raise
|
|
25
|
-
|
|
49
|
+
# Prefer server-provided retry hint when available.
|
|
50
|
+
hint = getattr(e, "retry_after", None)
|
|
51
|
+
base = float(hint) if isinstance(hint, int | float) and hint > 0 else backoff * (2**attempt)
|
|
52
|
+
if jitter > 0:
|
|
53
|
+
low = max(0.0, base * (1.0 - jitter))
|
|
54
|
+
high = base * (1.0 + jitter)
|
|
55
|
+
wait_time = random.uniform(low, high) # noqa: S311 (non-crypto jitter)
|
|
56
|
+
else:
|
|
57
|
+
wait_time = base
|
|
26
58
|
await asyncio.sleep(wait_time)
|
|
27
59
|
raise RuntimeError("Unreachable")
|
|
28
60
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
lixinger/__init__.py,sha256=VyNwJhDpm-NnDSskdx6bCHmG8iYcPSzX71HAJDtstxo,3290
|
|
2
2
|
lixinger/client.py,sha256=EZ6hbSyWSAL_kODJVDd3j6hUCGr57GiCQSvdu1U6GzM,15457
|
|
3
3
|
lixinger/config.py,sha256=JPz8EOrf1kP4Do-Z5-MsnOM0pMrNE1ZsP-Qoarh9y4c,2008
|
|
4
|
-
lixinger/exceptions.py,sha256=
|
|
4
|
+
lixinger/exceptions.py,sha256=Gkv1z-LC09paYGVs430-gDgDs43dLyRHbglB3Frehd4,1069
|
|
5
5
|
lixinger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
lixinger/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
lixinger/api/base.py,sha256=
|
|
7
|
+
lixinger/api/base.py,sha256=6YQK-2oPFTPeLzw8WaaG-rajFL1Vv8dxrpS1jkxF2ew,5685
|
|
8
8
|
lixinger/api/cn/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
lixinger/api/cn/company/__init__.py,sha256=K8aNp2gGj4iQOvc8KjXA5668QUAhwyTfMBPhP3tgtMg,961
|
|
10
10
|
lixinger/api/cn/company/announcement.py,sha256=lYt4AWOV5b9OTKfvJ7ZOqWP61pcPcyIQzb5N2DqFT6k,1920
|
|
@@ -120,8 +120,8 @@ lixinger/utils/api.py,sha256=BktR40JtKCMe9excFWo4ujsq3GiQK4ypJLG3MpJgo2s,402
|
|
|
120
120
|
lixinger/utils/dataframe.py,sha256=tYBrNdmJ4poyuwD-9XgzHt3A_A8bBo0cdHiu8Yy9e9A,2208
|
|
121
121
|
lixinger/utils/dict.py,sha256=yvbUtv8QpRmy0d_o_7gCuEwwiEfBji5_xB490ANxilw,589
|
|
122
122
|
lixinger/utils/rate_limiter.py,sha256=ZHlRTTL60L62uRA_f--LD26qkAfk-pHBllRqPapAXQM,1141
|
|
123
|
-
lixinger/utils/retry.py,sha256=
|
|
124
|
-
lixinger_python-0.3.
|
|
125
|
-
lixinger_python-0.3.
|
|
126
|
-
lixinger_python-0.3.
|
|
127
|
-
lixinger_python-0.3.
|
|
123
|
+
lixinger/utils/retry.py,sha256=C9QBcrpD7WYAuT8R4SJfWNUZFj1mZsv-qyTRIFQDmwc,2862
|
|
124
|
+
lixinger_python-0.3.14.dist-info/METADATA,sha256=mXD9Q3vhJMy-p0Exnj0knq71zUNpengedXCygEMcHyg,9207
|
|
125
|
+
lixinger_python-0.3.14.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
126
|
+
lixinger_python-0.3.14.dist-info/licenses/LICENSE,sha256=5oOwRq1lHSOScbNGCHr2feuNnhNYdGcArj6fSUfsC5U,1064
|
|
127
|
+
lixinger_python-0.3.14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|