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.
- {python_sendparcel_inpost-0.1.2.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/METADATA +8 -2
- python_sendparcel_inpost-0.3.0.dist-info/RECORD +17 -0
- {python_sendparcel_inpost-0.1.2.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/WHEEL +1 -1
- python_sendparcel_inpost-0.3.0.dist-info/licenses/LICENSE +10 -0
- sendparcel_inpost/__init__.py +5 -1
- sendparcel_inpost/circuit_breaker.py +155 -0
- sendparcel_inpost/client.py +282 -34
- sendparcel_inpost/exceptions.py +29 -0
- sendparcel_inpost/providers/__init__.py +6 -1
- sendparcel_inpost/providers/base.py +414 -0
- sendparcel_inpost/providers/courier.py +29 -252
- sendparcel_inpost/providers/locker.py +6 -233
- sendparcel_inpost/py.typed +0 -0
- sendparcel_inpost/rate_limiter.py +57 -0
- sendparcel_inpost/status_mapping.py +23 -13
- python_sendparcel_inpost-0.1.2.dist-info/RECORD +0 -13
- sendparcel_inpost/enums.py +0 -18
- {python_sendparcel_inpost-0.1.2.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/entry_points.txt +0 -0
{python_sendparcel_inpost-0.1.2.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/METADATA
RENAMED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel-inpost
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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,,
|
|
@@ -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.
|
sendparcel_inpost/__init__.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""InPost ShipX provider for python-sendparcel."""
|
|
2
2
|
|
|
3
|
-
__version__ = "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()
|
sendparcel_inpost/client.py
CHANGED
|
@@ -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) ->
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|