lixinger-python 0.3.12__py3-none-any.whl → 0.3.13__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
@@ -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
 
@@ -29,6 +31,39 @@ def get_global_client() -> httpx.AsyncClient:
29
31
  return _global_http_client
30
32
 
31
33
 
34
+ def _parse_retry_after(header_value: str | None) -> float | None:
35
+ """Parse a Retry-After header into seconds.
36
+
37
+ Accepts both formats defined by RFC 7231 §7.1.3:
38
+ - ``delta-seconds`` (e.g. ``"120"``)
39
+ - ``HTTP-date`` (e.g. ``"Wed, 21 Oct 2015 07:28:00 GMT"``)
40
+
41
+ Returns ``None`` when the header is missing or unparseable so the caller
42
+ can fall back to its default backoff.
43
+ """
44
+ if not header_value:
45
+ return None
46
+ value = header_value.strip()
47
+ # Try delta-seconds first — the common case.
48
+ try:
49
+ seconds = float(value)
50
+ except ValueError:
51
+ pass
52
+ else:
53
+ return seconds if seconds >= 0 else None
54
+ # Fall back to HTTP-date.
55
+ try:
56
+ target = parsedate_to_datetime(value)
57
+ except (TypeError, ValueError):
58
+ return None
59
+ if target.tzinfo is None:
60
+ # RFC 7231 HTTP-date is always GMT, so treat naive dates as UTC.
61
+ target = target.replace(tzinfo=UTC)
62
+ now = datetime.now(tz=target.tzinfo)
63
+ delta = (target - now).total_seconds()
64
+ return max(delta, 0.0)
65
+
66
+
32
67
  class BaseAPI:
33
68
  """Base class for API endpoint groups."""
34
69
 
@@ -42,7 +77,12 @@ class BaseAPI:
42
77
  self._config = config or settings
43
78
  self._rate_limiter = rate_limiter or _global_rate_limiter
44
79
 
45
- @async_retry_on_failure(max_retries=3, backoff=1.0)
80
+ @async_retry_on_failure(
81
+ max_retries=3,
82
+ backoff=1.0,
83
+ # Do NOT retry non-transient errors even though they inherit from Exception.
84
+ no_retry_on=(AuthenticationError, ValidationError),
85
+ )
46
86
  async def _request(
47
87
  self,
48
88
  method: str,
@@ -72,7 +112,12 @@ class BaseAPI:
72
112
  )
73
113
 
74
114
  if response.status_code == 429:
75
- raise RateLimitError("Rate limit exceeded", status_code=429)
115
+ retry_after = _parse_retry_after(response.headers.get("Retry-After"))
116
+ raise RateLimitError(
117
+ "Rate limit exceeded",
118
+ status_code=429,
119
+ retry_after=retry_after,
120
+ )
76
121
  if response.status_code == 401:
77
122
  raise AuthenticationError("Authentication failed", status_code=401)
78
123
  if response.status_code != 200:
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
@@ -10,8 +10,22 @@ def async_retry_on_failure(
10
10
  max_retries: int = 3,
11
11
  backoff: float = 1.0,
12
12
  retry_on: tuple[type[Exception], ...] = (Exception,),
13
+ no_retry_on: tuple[type[Exception], ...] = (),
13
14
  ) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
14
- """Retry failed async requests with exponential backoff."""
15
+ """Retry failed async requests with exponential backoff.
16
+
17
+ Args:
18
+ max_retries: Maximum number of retry attempts (total attempts = max_retries + 1).
19
+ backoff: Base delay in seconds for exponential backoff (2**attempt * backoff).
20
+ retry_on: Exception types that trigger a retry.
21
+ no_retry_on: Exception types that must NOT be retried even if matched by
22
+ ``retry_on``. Use this to short-circuit on non-transient errors such
23
+ as authentication failures.
24
+
25
+ If the caught exception exposes a ``retry_after`` attribute (float seconds,
26
+ typically parsed from a ``Retry-After`` HTTP header on 429 responses), that
27
+ value is used for the next sleep instead of the exponential backoff.
28
+ """
15
29
 
16
30
  def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
17
31
  @wraps(func)
@@ -20,9 +34,16 @@ def async_retry_on_failure(
20
34
  try:
21
35
  return await func(*args, **kwargs)
22
36
  except retry_on as e:
37
+ # Bail out immediately on non-transient errors.
38
+ if no_retry_on and isinstance(e, no_retry_on):
39
+ raise
23
40
  if attempt == max_retries:
24
41
  raise
25
- wait_time = backoff * (2**attempt)
42
+ # Prefer server-provided retry hint when available.
43
+ 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
+ )
26
47
  await asyncio.sleep(wait_time)
27
48
  raise RuntimeError("Unreachable")
28
49
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lixinger-python
3
- Version: 0.3.12
3
+ Version: 0.3.13
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
@@ -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=haJOQzZinH0tKk_e2BTUzJfPbtnyLHyRR7HSnWHeKfk,516
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=j90uXrBEIHyPkTu-4hF1876HtsWIPM_J7tunFi-1O1Q,3182
7
+ lixinger/api/base.py,sha256=oMXwauTncyd_anFS_2A642lH7NjUsp6uGtntVY4KoeE,4684
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=sXtb0ESp12_JedjzDxoeIH48TlOrbxtIA0j1V33DW7M,1026
124
- lixinger_python-0.3.12.dist-info/METADATA,sha256=qsxT6v3LsbjVsEa3OfQb0usA0dXDj3IU8VVnRUVDExg,9207
125
- lixinger_python-0.3.12.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
126
- lixinger_python-0.3.12.dist-info/licenses/LICENSE,sha256=5oOwRq1lHSOScbNGCHr2feuNnhNYdGcArj6fSUfsC5U,1064
127
- lixinger_python-0.3.12.dist-info/RECORD,,
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,,