lixinger-python 0.3.13__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 CHANGED
@@ -15,6 +15,12 @@ _global_rate_limiter = AsyncRateLimiter(max_requests=1000)
15
15
  # Lazy initialization of global http client to avoid issues with proxy/config changes
16
16
  _global_http_client: httpx.AsyncClient | None = None
17
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
+
18
24
 
19
25
  def get_global_client() -> httpx.AsyncClient:
20
26
  """Get or create the global HTTP client."""
@@ -113,6 +119,10 @@ class BaseAPI:
113
119
 
114
120
  if response.status_code == 429:
115
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
116
126
  raise RateLimitError(
117
127
  "Rate limit exceeded",
118
128
  status_code=429,
@@ -133,7 +143,15 @@ class BaseAPI:
133
143
  code = data.get("code")
134
144
  message = data.get("message")
135
145
 
136
- if code != 1 or message != "success":
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:
137
152
  raise APIError(f"API returned error code {code}: {message}")
138
153
 
139
- return data.get("data")
154
+ payload = data.get("data")
155
+ if payload is None:
156
+ return []
157
+ return payload
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")
@@ -11,8 +12,9 @@ def async_retry_on_failure(
11
12
  backoff: float = 1.0,
12
13
  retry_on: tuple[type[Exception], ...] = (Exception,),
13
14
  no_retry_on: tuple[type[Exception], ...] = (),
15
+ jitter: float = 0.2,
14
16
  ) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
15
- """Retry failed async requests with exponential backoff.
17
+ """Retry failed async requests with exponential backoff and jitter.
16
18
 
17
19
  Args:
18
20
  max_retries: Maximum number of retry attempts (total attempts = max_retries + 1).
@@ -21,6 +23,11 @@ def async_retry_on_failure(
21
23
  no_retry_on: Exception types that must NOT be retried even if matched by
22
24
  ``retry_on``. Use this to short-circuit on non-transient errors such
23
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.
24
31
 
25
32
  If the caught exception exposes a ``retry_after`` attribute (float seconds,
26
33
  typically parsed from a ``Retry-After`` HTTP header on 429 responses), that
@@ -41,9 +48,13 @@ def async_retry_on_failure(
41
48
  raise
42
49
  # Prefer server-provided retry hint when available.
43
50
  hint = getattr(e, "retry_after", None)
44
- wait_time = (
45
- float(hint) if isinstance(hint, int | float) and hint > 0 else backoff * (2**attempt)
46
- )
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
47
58
  await asyncio.sleep(wait_time)
48
59
  raise RuntimeError("Unreachable")
49
60
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lixinger-python
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: Python SDK for Lixinger Financial Data API
5
5
  Project-URL: Homepage, https://www.lixinger.com
6
6
  Project-URL: Documentation, https://www.lixinger.com/open/api/doc
@@ -4,7 +4,7 @@ lixinger/config.py,sha256=JPz8EOrf1kP4Do-Z5-MsnOM0pMrNE1ZsP-Qoarh9y4c,2008
4
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=oMXwauTncyd_anFS_2A642lH7NjUsp6uGtntVY4KoeE,4684
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=erwB-wc9PA85vEoJD7yQFZTipDTFgsABD4QEOG7c_VM,2169
124
- lixinger_python-0.3.13.dist-info/METADATA,sha256=5i65s2BVbG3ArH0Yzrf_95Uiue20D4Y2z5u8SHVdQIk,9207
125
- lixinger_python-0.3.13.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
126
- lixinger_python-0.3.13.dist-info/licenses/LICENSE,sha256=5oOwRq1lHSOScbNGCHr2feuNnhNYdGcArj6fSUfsC5U,1064
127
- lixinger_python-0.3.13.dist-info/RECORD,,
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,,