python-sendparcel-inpost 0.1.2__py3-none-any.whl → 0.3.0__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.
@@ -1,9 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel-inpost
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: InPost ShipX provider for python-sendparcel.
5
+ Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel-inpost
6
+ Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel-inpost
7
+ Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/CHANGELOG.md
8
+ Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel-inpost/issues
5
9
  Author-email: Dominik Kozaczko <dominik@kozaczko.info>
6
10
  License: MIT
11
+ License-File: LICENSE
7
12
  Keywords: inpost,parcel,sendparcel,shipping,shipx
8
13
  Classifier: Development Status :: 3 - Alpha
9
14
  Classifier: Intended Audience :: Developers
@@ -17,13 +22,14 @@ Classifier: Typing :: Typed
17
22
  Requires-Python: >=3.12
18
23
  Requires-Dist: anyio>=4.0
19
24
  Requires-Dist: httpx>=0.27.0
20
- Requires-Dist: python-sendparcel>=0.1.1
25
+ Requires-Dist: python-sendparcel>=0.3.0
21
26
  Provides-Extra: dev
22
27
  Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
23
28
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
29
  Requires-Dist: pytest>=8.0; extra == 'dev'
25
30
  Requires-Dist: respx>=0.22.0; extra == 'dev'
26
31
  Requires-Dist: ruff>=0.9.0; extra == 'dev'
32
+ Requires-Dist: ty>=0.0.1a11; extra == 'dev'
27
33
  Description-Content-Type: text/markdown
28
34
 
29
35
  # python-sendparcel-inpost
@@ -0,0 +1,17 @@
1
+ sendparcel_inpost/__init__.py,sha256=Tkz5IeAveOLj_1CtR8xKfuon2URY9rst-qsKY66yLqI,548
2
+ sendparcel_inpost/circuit_breaker.py,sha256=RZ6Sry63Jh9y7OdUCdXACxOIIfkgyiGggFG7h34DPCA,4939
3
+ sendparcel_inpost/client.py,sha256=jftpFhvmcQDmpkAm8Bac0tqW5w4IxoC1frB9nso-Mqs,14920
4
+ sendparcel_inpost/exceptions.py,sha256=E79mErjpni41_U9A2Ql2Cw4yAKd82cpguRPwNiamg2Y,2142
5
+ sendparcel_inpost/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ sendparcel_inpost/rate_limiter.py,sha256=sct5bYofsdKGLcGrCUXWeciyGIq_yr332kP7oLpTCZE,1870
7
+ sendparcel_inpost/status_mapping.py,sha256=9UDU8XFCWKfKXPyWTV9E_dzgExdZjgZrBkcWUYj2smI,2782
8
+ sendparcel_inpost/types.py,sha256=Pu-OJpC6te4_gY6PLv-Ag0o2IoulYytdr9t0FGf4GIA,1426
9
+ sendparcel_inpost/providers/__init__.py,sha256=akn431q6mekenZV5Y4wjCh2UkLSi2s51umBHZ37orJg,336
10
+ sendparcel_inpost/providers/base.py,sha256=7uQ8e0K0NPHOQ1-JM9XGyc5OzWd3OKUO_Goqm65sL0M,13952
11
+ sendparcel_inpost/providers/courier.py,sha256=gdjZmoBlLTQa73x_JRameJQYCg2F9WhBwt2S-_0r2UU,2592
12
+ sendparcel_inpost/providers/locker.py,sha256=mzn5WvnCi7lLWPoWW7NaHMty-vb4gzFSpgb9ucCHV0o,2867
13
+ python_sendparcel_inpost-0.3.0.dist-info/METADATA,sha256=IRnCMaIs50L-d62gMvbzSd848VITd2JHIeo8_TUqPMM,3446
14
+ python_sendparcel_inpost-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ python_sendparcel_inpost-0.3.0.dist-info/entry_points.txt,sha256=XuQdmE0LIuIc3TvGPWnYjoB14zXCEof6Q8h1tMp2arE,170
16
+ python_sendparcel_inpost-0.3.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
17
+ python_sendparcel_inpost-0.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,10 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2025, Dominik Kozaczko
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,14 +1,18 @@
1
1
  """InPost ShipX provider for python-sendparcel."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from sendparcel_inpost.client import ShipXClient
6
+ from sendparcel_inpost.exceptions import CircuitBreakerError
7
+ from sendparcel_inpost.providers.base import aclose_transports
6
8
  from sendparcel_inpost.providers.courier import InPostCourierProvider
7
9
  from sendparcel_inpost.providers.locker import InPostLockerProvider
8
10
 
9
11
  __all__ = [
12
+ "CircuitBreakerError",
10
13
  "InPostCourierProvider",
11
14
  "InPostLockerProvider",
12
15
  "ShipXClient",
13
16
  "__version__",
17
+ "aclose_transports",
14
18
  ]
@@ -0,0 +1,155 @@
1
+ """Circuit breaker and metrics for external API resilience."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class ShipXMetrics:
13
+ """Collects metrics for a single ShipXClient instance.
14
+
15
+ Thread-safe. Use ``snapshot()`` to get a point-in-time view.
16
+ """
17
+
18
+ _lock: threading.Lock = field(default_factory=threading.Lock)
19
+ _request_count: int = 0
20
+ _error_count: int = 0
21
+ _circuit_trip_count: int = 0
22
+ _retry_count: int = 0
23
+ _total_latency_ms: float = 0.0
24
+ _max_latency_ms: float = 0.0
25
+ _last_error: str | None = None
26
+ _last_error_at: float | None = None
27
+ _circuit_state: str = "closed"
28
+
29
+ @property
30
+ def circuit_state(self) -> str:
31
+ with self._lock:
32
+ return self._circuit_state
33
+
34
+ def set_circuit_state(self, state: str) -> None:
35
+ with self._lock:
36
+ self._circuit_state = state
37
+
38
+ def record_request(
39
+ self,
40
+ latency_ms: float,
41
+ success: bool,
42
+ error: str | None = None,
43
+ ) -> None:
44
+ with self._lock:
45
+ self._request_count += 1
46
+ self._total_latency_ms += latency_ms
47
+ if latency_ms > self._max_latency_ms:
48
+ self._max_latency_ms = latency_ms
49
+ if not success:
50
+ self._error_count += 1
51
+ self._last_error = error
52
+ self._last_error_at = time.time()
53
+
54
+ def record_circuit_trip(self) -> None:
55
+ with self._lock:
56
+ self._circuit_trip_count += 1
57
+
58
+ def record_retry(self) -> None:
59
+ with self._lock:
60
+ self._retry_count += 1
61
+
62
+ def snapshot(self) -> dict[str, Any]:
63
+ """Return a point-in-time copy of all metrics."""
64
+ with self._lock:
65
+ avg_latency = (
66
+ self._total_latency_ms / self._request_count
67
+ if self._request_count > 0
68
+ else 0.0
69
+ )
70
+ return {
71
+ "request_count": self._request_count,
72
+ "error_count": self._error_count,
73
+ "retry_count": self._retry_count,
74
+ "circuit_trip_count": self._circuit_trip_count,
75
+ "circuit_state": self._circuit_state,
76
+ "avg_latency_ms": round(avg_latency, 2),
77
+ "max_latency_ms": round(self._max_latency_ms, 2),
78
+ "last_error": self._last_error,
79
+ "last_error_at": self._last_error_at,
80
+ }
81
+
82
+
83
+ class _CircuitBreaker:
84
+ """Simple circuit breaker for the ShipX API.
85
+
86
+ States:
87
+ - CLOSED: normal operation, requests pass through
88
+ - OPEN: circuit tripped, requests fail fast
89
+ - HALF_OPEN: one probe request allowed after cooldown
90
+
91
+ Transitions:
92
+ - CLOSED → OPEN: after `threshold` consecutive failures
93
+ - OPEN → HALF_OPEN: after `cooldown` seconds
94
+ - HALF_OPEN → CLOSED: probe request succeeds
95
+ - HALF_OPEN → OPEN: probe request fails
96
+ """
97
+
98
+ class State:
99
+ CLOSED = "closed"
100
+ OPEN = "open"
101
+ HALF_OPEN = "half_open"
102
+
103
+ def __init__(
104
+ self,
105
+ threshold: int = 5,
106
+ cooldown: float = 30.0,
107
+ ) -> None:
108
+ self._threshold = threshold
109
+ self._cooldown = cooldown
110
+ self._state = self.State.CLOSED
111
+ self._failures = 0
112
+ self._opened_at: float | None = None
113
+
114
+ @property
115
+ def state(self) -> str:
116
+ """Current circuit state, transitioning OPEN → HALF_OPEN on cooldown."""
117
+ if self._state == self.State.OPEN and self._opened_at is not None:
118
+ elapsed = time.monotonic() - self._opened_at
119
+ if elapsed >= self._cooldown:
120
+ self._state = self.State.HALF_OPEN
121
+ return self._state
122
+
123
+ @property
124
+ def failures(self) -> int:
125
+ return self._failures
126
+
127
+ @property
128
+ def cooldown_remaining(self) -> float:
129
+ if self._state != self.State.OPEN or self._opened_at is None:
130
+ return 0.0
131
+ elapsed = time.monotonic() - self._opened_at
132
+ return max(0.0, self._cooldown - elapsed)
133
+
134
+ def allow_request(self) -> bool:
135
+ """Check if a request should be allowed through."""
136
+ current = self.state # triggers OPEN → HALF_OPEN transition
137
+ return current != self.State.OPEN
138
+
139
+ def record_success(self) -> None:
140
+ """Record a successful request."""
141
+ self._failures = 0
142
+ self._state = self.State.CLOSED
143
+ self._opened_at = None
144
+
145
+ def record_failure(self) -> None:
146
+ """Record a failed request."""
147
+ self._failures += 1
148
+ if self.state == self.State.HALF_OPEN:
149
+ # Probe failed — reopen circuit
150
+ self._state = self.State.OPEN
151
+ self._opened_at = time.monotonic()
152
+ elif self._failures >= self._threshold:
153
+ # Threshold reached — open circuit
154
+ self._state = self.State.OPEN
155
+ self._opened_at = time.monotonic()
@@ -1,20 +1,37 @@
1
1
  """ShipX API async HTTP client."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
3
7
  from types import TracebackType
4
- from typing import Any
8
+ from typing import Any, cast
5
9
 
10
+ import anyio
6
11
  import httpx
7
12
 
13
+ from sendparcel_inpost.circuit_breaker import ShipXMetrics, _CircuitBreaker
8
14
  from sendparcel_inpost.exceptions import (
15
+ CircuitBreakerError,
9
16
  ShipXAPIError,
10
17
  ShipXAuthenticationError,
11
18
  ShipXValidationError,
12
19
  )
20
+ from sendparcel_inpost.rate_limiter import _TokenBucket
21
+
22
+ logger = logging.getLogger(__name__)
23
+
13
24
 
14
25
  PRODUCTION_BASE_URL = "https://api-shipx-pl.easypack24.net"
15
26
  SANDBOX_BASE_URL = "https://sandbox-api-shipx-pl.easypack24.net"
16
27
 
17
28
  DEFAULT_TIMEOUT = 30.0
29
+ DEFAULT_RATE_LIMIT = 10.0 # requests per second
30
+ DEFAULT_BURST = 5 # maximum burst size
31
+ DEFAULT_MAX_RETRIES = 3
32
+ DEFAULT_RETRY_BASE_DELAY = 1.0 # seconds
33
+ DEFAULT_CIRCUIT_THRESHOLD = 5 # consecutive failures to open circuit
34
+ DEFAULT_CIRCUIT_COOLDOWN = 30.0 # seconds before allowing probe request
18
35
 
19
36
 
20
37
  class ShipXClient:
@@ -22,10 +39,18 @@ class ShipXClient:
22
39
 
23
40
  Can be used standalone (independent of sendparcel providers).
24
41
 
25
- Usage::
42
+ Usage as context manager::
26
43
 
27
44
  async with ShipXClient(token="...", organization_id=123) as client:
28
45
  result = await client.create_shipment(payload={...})
46
+
47
+ Usage as long-lived client (shared across multiple calls)::
48
+
49
+ client = ShipXClient(token="...", organization_id=123)
50
+ try:
51
+ result = await client.create_shipment(payload={...})
52
+ finally:
53
+ await client.close()
29
54
  """
30
55
 
31
56
  def __init__(
@@ -36,6 +61,12 @@ class ShipXClient:
36
61
  sandbox: bool = False,
37
62
  base_url: str | None = None,
38
63
  timeout: float = DEFAULT_TIMEOUT,
64
+ rate_limit: float = DEFAULT_RATE_LIMIT,
65
+ burst: int = DEFAULT_BURST,
66
+ max_retries: int = DEFAULT_MAX_RETRIES,
67
+ retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
68
+ circuit_threshold: int = DEFAULT_CIRCUIT_THRESHOLD,
69
+ circuit_cooldown: float = DEFAULT_CIRCUIT_COOLDOWN,
39
70
  ) -> None:
40
71
  if base_url is not None:
41
72
  self.base_url = base_url
@@ -45,6 +76,13 @@ class ShipXClient:
45
76
  self.base_url = PRODUCTION_BASE_URL
46
77
  self.organization_id = organization_id
47
78
  self.timeout = timeout
79
+ self._max_retries = max_retries
80
+ self._retry_base_delay = retry_base_delay
81
+ self._circuit = _CircuitBreaker(
82
+ threshold=circuit_threshold,
83
+ cooldown=circuit_cooldown,
84
+ )
85
+ self._metrics = ShipXMetrics()
48
86
  self._http = httpx.AsyncClient(
49
87
  base_url=self.base_url,
50
88
  headers={
@@ -53,8 +91,9 @@ class ShipXClient:
53
91
  },
54
92
  timeout=timeout,
55
93
  )
94
+ self._rate_limiter = _TokenBucket(rate=rate_limit, burst=burst)
56
95
 
57
- async def __aenter__(self) -> "ShipXClient":
96
+ async def __aenter__(self) -> ShipXClient:
58
97
  return self
59
98
 
60
99
  async def __aexit__(
@@ -69,26 +108,187 @@ class ShipXClient:
69
108
  """Close the underlying HTTP client."""
70
109
  await self._http.aclose()
71
110
 
111
+ @property
112
+ def metrics(self) -> ShipXMetrics:
113
+ """Return the metrics collector for this client."""
114
+ return self._metrics
115
+
116
+ async def _retry(
117
+ self,
118
+ coro: Any,
119
+ *,
120
+ idempotent: bool = True,
121
+ ) -> Any:
122
+ """Execute a coroutine with exponential backoff retry
123
+ and circuit breaker.
124
+
125
+ The circuit breaker fails fast when the API is consistently down,
126
+ preventing requests from queuing up and waiting 30s each.
127
+
128
+ Args:
129
+ coro: The coroutine to execute.
130
+ idempotent: If True, retry on all transient errors.
131
+ If False, only retry on server errors (5xx).
132
+ POST operations should use idempotent=False unless
133
+ an idempotency key is provided.
134
+ """
135
+ start = time.monotonic()
136
+ retries = 0
137
+ op_name = getattr(coro, "__qualname__", repr(coro))
138
+
139
+ # Check circuit breaker before attempting any work
140
+ if not self._circuit.allow_request():
141
+ self._metrics.record_circuit_trip()
142
+ self._metrics.set_circuit_state(self._circuit.state)
143
+ raise CircuitBreakerError(
144
+ "Circuit breaker is open — InPost API is unavailable",
145
+ failures=self._circuit.failures,
146
+ cooldown_remaining=self._circuit.cooldown_remaining,
147
+ )
148
+
149
+ last_exc: Exception | None = None
150
+ for attempt in range(1 + self._max_retries):
151
+ try:
152
+ result = await coro()
153
+ self._circuit.record_success()
154
+ self._metrics.set_circuit_state(self._circuit.state)
155
+ latency_ms = (time.monotonic() - start) * 1000
156
+ self._metrics.record_request(latency_ms, success=True)
157
+ return result
158
+ except (
159
+ ShipXAuthenticationError,
160
+ ShipXValidationError,
161
+ ) as exc:
162
+ # These are client errors — do not retry, do not trip circuit.
163
+ latency_ms = (time.monotonic() - start) * 1000
164
+ self._metrics.record_request(
165
+ latency_ms,
166
+ success=False,
167
+ error=f"{type(exc).__name__}: {exc}",
168
+ )
169
+ raise
170
+ except (
171
+ httpx.ConnectError,
172
+ httpx.ConnectTimeout,
173
+ httpx.ReadTimeout,
174
+ httpx.WriteTimeout,
175
+ httpx.RemoteProtocolError,
176
+ httpx.PoolTimeout,
177
+ ) as exc:
178
+ last_exc = exc
179
+ self._circuit.record_failure()
180
+ self._metrics.set_circuit_state(self._circuit.state)
181
+ if attempt < self._max_retries:
182
+ retries += 1
183
+ self._metrics.record_retry()
184
+ delay = self._retry_base_delay * (2**attempt)
185
+ logger.warning(
186
+ "Retry %d/%d for %s after %.1fs: %s",
187
+ attempt + 1,
188
+ self._max_retries,
189
+ op_name,
190
+ delay,
191
+ exc,
192
+ )
193
+ await anyio.sleep(delay)
194
+ else:
195
+ latency_ms = (time.monotonic() - start) * 1000
196
+ self._metrics.record_request(
197
+ latency_ms,
198
+ success=False,
199
+ error=f"{type(exc).__name__}: {exc}",
200
+ )
201
+ raise
202
+ except ShipXAPIError as exc:
203
+ # 429: the request was rejected before processing, so it
204
+ # is safe to retry regardless of idempotency. Rate
205
+ # limiting is not a service failure — don't trip the
206
+ # circuit. 5xx: retry only idempotent operations.
207
+ rate_limited = exc.status_code == 429
208
+ retryable = rate_limited or (
209
+ exc.status_code >= 500 and idempotent
210
+ )
211
+ if retryable:
212
+ last_exc = exc
213
+ if not rate_limited:
214
+ self._circuit.record_failure()
215
+ self._metrics.set_circuit_state(self._circuit.state)
216
+ if attempt < self._max_retries:
217
+ retries += 1
218
+ self._metrics.record_retry()
219
+ delay = self._retry_base_delay * (2**attempt)
220
+ if rate_limited and exc.retry_after is not None:
221
+ delay = exc.retry_after
222
+ logger.warning(
223
+ "Retry %d/%d for %s after %.1fs: HTTP %d %s",
224
+ attempt + 1,
225
+ self._max_retries,
226
+ op_name,
227
+ delay,
228
+ exc.status_code,
229
+ exc.detail,
230
+ )
231
+ await anyio.sleep(delay)
232
+ else:
233
+ latency_ms = (time.monotonic() - start) * 1000
234
+ self._metrics.record_request(
235
+ latency_ms,
236
+ success=False,
237
+ error=f"HTTP {exc.status_code} {exc.detail}",
238
+ )
239
+ raise
240
+ else:
241
+ latency_ms = (time.monotonic() - start) * 1000
242
+ self._metrics.record_request(
243
+ latency_ms,
244
+ success=False,
245
+ error=f"HTTP {exc.status_code} {exc.detail}",
246
+ )
247
+ raise
248
+
249
+ assert last_exc is not None
250
+ latency_ms = (time.monotonic() - start) * 1000
251
+ self._metrics.record_request(
252
+ latency_ms,
253
+ success=False,
254
+ error=f"{type(last_exc).__name__}: {last_exc}",
255
+ )
256
+ raise last_exc
257
+
258
+ async def _rate_limit(self) -> None:
259
+ """Apply rate limiting before making an API call."""
260
+ await self._rate_limiter.acquire()
261
+
72
262
  async def create_shipment(self, payload: dict[str, Any]) -> dict[str, Any]:
73
263
  """Create a shipment via simplified flow.
74
264
 
75
265
  POST /v1/organizations/{org_id}/shipments
76
266
  """
77
- url = f"/v1/organizations/{self.organization_id}/shipments"
78
- response = await self._http.post(url, json=payload)
79
- self._raise_for_status(response)
80
- result: dict[str, Any] = response.json()
81
- return result
267
+
268
+ async def _do() -> dict[str, Any]:
269
+ await self._rate_limit()
270
+ url = f"/v1/organizations/{self.organization_id}/shipments"
271
+ response = await self._http.post(url, json=payload)
272
+ self._raise_for_status(response)
273
+ result: dict[str, Any] = cast(dict[str, Any], response.json())
274
+ return result
275
+
276
+ return cast(dict[str, Any], await self._retry(_do, idempotent=False))
82
277
 
83
278
  async def get_shipment(self, shipment_id: int) -> dict[str, Any]:
84
279
  """Fetch shipment details.
85
280
 
86
281
  GET /v1/shipments/{shipment_id}
87
282
  """
88
- response = await self._http.get(f"/v1/shipments/{shipment_id}")
89
- self._raise_for_status(response)
90
- result: dict[str, Any] = response.json()
91
- return result
283
+
284
+ async def _do() -> dict[str, Any]:
285
+ await self._rate_limit()
286
+ response = await self._http.get(f"/v1/shipments/{shipment_id}")
287
+ self._raise_for_status(response)
288
+ result: dict[str, Any] = cast(dict[str, Any], response.json())
289
+ return result
290
+
291
+ return cast(dict[str, Any], await self._retry(_do, idempotent=True))
92
292
 
93
293
  async def get_label(
94
294
  self,
@@ -101,53 +301,88 @@ class ShipXClient:
101
301
 
102
302
  GET /v1/shipments/{shipment_id}/label?format=...&type=...
103
303
  """
104
- response = await self._http.get(
105
- f"/v1/shipments/{shipment_id}/label",
106
- params={"format": label_format, "type": label_type},
107
- )
108
- self._raise_for_status(response)
109
- return response.content
304
+
305
+ async def _do() -> bytes:
306
+ await self._rate_limit()
307
+ response = await self._http.get(
308
+ f"/v1/shipments/{shipment_id}/label",
309
+ params={"format": label_format, "type": label_type},
310
+ )
311
+ self._raise_for_status(response)
312
+ content = response.content
313
+ assert isinstance(content, bytes)
314
+ return content
315
+
316
+ return cast(bytes, await self._retry(_do, idempotent=True))
110
317
 
111
318
  async def cancel_shipment(self, shipment_id: int) -> None:
112
319
  """Cancel a shipment.
113
320
 
114
321
  DELETE /v1/shipments/{shipment_id}
115
322
  """
116
- response = await self._http.delete(f"/v1/shipments/{shipment_id}")
117
- self._raise_for_status(response)
323
+
324
+ async def _do() -> None:
325
+ await self._rate_limit()
326
+ response = await self._http.delete(f"/v1/shipments/{shipment_id}")
327
+ self._raise_for_status(response)
328
+
329
+ await self._retry(_do, idempotent=True)
118
330
 
119
331
  async def get_tracking(self, tracking_number: str) -> dict[str, Any]:
120
332
  """Fetch public tracking data (no auth required).
121
333
 
122
334
  GET /v1/tracking/{tracking_number}
123
335
  """
124
- response = await self._http.get(f"/v1/tracking/{tracking_number}")
125
- self._raise_for_status(response)
126
- result: dict[str, Any] = response.json()
127
- return result
336
+
337
+ async def _do() -> dict[str, Any]:
338
+ await self._rate_limit()
339
+ response = await self._http.get(f"/v1/tracking/{tracking_number}")
340
+ self._raise_for_status(response)
341
+ result: dict[str, Any] = cast(dict[str, Any], response.json())
342
+ return result
343
+
344
+ return cast(dict[str, Any], await self._retry(_do, idempotent=True))
128
345
 
129
346
  async def get_statuses(self, lang: str = "pl") -> list[dict[str, Any]]:
130
347
  """Fetch list of all ShipX statuses.
131
348
 
132
349
  GET /v1/statuses
133
350
  """
134
- response = await self._http.get(
135
- "/v1/statuses",
136
- params={"lang": lang},
351
+
352
+ async def _do() -> list[dict[str, Any]]:
353
+ await self._rate_limit()
354
+ response = await self._http.get(
355
+ "/v1/statuses",
356
+ params={"lang": lang},
357
+ )
358
+ self._raise_for_status(response)
359
+ result: list[dict[str, Any]] = cast(
360
+ list[dict[str, Any]], response.json()
361
+ )
362
+ return result
363
+
364
+ return cast(
365
+ list[dict[str, Any]], await self._retry(_do, idempotent=True)
137
366
  )
138
- self._raise_for_status(response)
139
- result: list[dict[str, Any]] = response.json()
140
- return result
141
367
 
142
368
  async def get_services(self) -> list[dict[str, Any]]:
143
369
  """Fetch list of all ShipX services.
144
370
 
145
371
  GET /v1/services
146
372
  """
147
- response = await self._http.get("/v1/services")
148
- self._raise_for_status(response)
149
- result: list[dict[str, Any]] = response.json()
150
- return result
373
+
374
+ async def _do() -> list[dict[str, Any]]:
375
+ await self._rate_limit()
376
+ response = await self._http.get("/v1/services")
377
+ self._raise_for_status(response)
378
+ result: list[dict[str, Any]] = cast(
379
+ list[dict[str, Any]], response.json()
380
+ )
381
+ return result
382
+
383
+ return cast(
384
+ list[dict[str, Any]], await self._retry(_do, idempotent=True)
385
+ )
151
386
 
152
387
  def _raise_for_status(self, response: httpx.Response) -> None:
153
388
  """Raise ShipXAPIError subclasses for non-2xx responses."""
@@ -174,4 +409,17 @@ class ShipXClient:
174
409
  status_code=status_code,
175
410
  detail=str(detail),
176
411
  errors=errors,
412
+ retry_after=_parse_retry_after(response),
177
413
  )
414
+
415
+
416
+ def _parse_retry_after(response: httpx.Response) -> float | None:
417
+ """Parse the Retry-After header as delay seconds, if present."""
418
+ raw = response.headers.get("Retry-After")
419
+ if raw is None:
420
+ return None
421
+ try:
422
+ return max(0.0, float(raw))
423
+ except ValueError:
424
+ # HTTP-date form — not worth parsing; fall back to backoff.
425
+ return None