python-sendparcel-inpost 0.2.0__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.2.0.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/METADATA +2 -2
- python_sendparcel_inpost-0.3.0.dist-info/RECORD +17 -0
- sendparcel_inpost/__init__.py +3 -1
- sendparcel_inpost/circuit_breaker.py +155 -0
- sendparcel_inpost/client.py +35 -361
- sendparcel_inpost/exceptions.py +2 -0
- sendparcel_inpost/providers/base.py +130 -84
- sendparcel_inpost/providers/courier.py +0 -8
- sendparcel_inpost/providers/locker.py +0 -8
- sendparcel_inpost/rate_limiter.py +57 -0
- sendparcel_inpost/status_mapping.py +23 -13
- python_sendparcel_inpost-0.2.0.dist-info/RECORD +0 -16
- sendparcel_inpost/enums.py +0 -18
- {python_sendparcel_inpost-0.2.0.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/WHEEL +0 -0
- {python_sendparcel_inpost-0.2.0.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_sendparcel_inpost-0.2.0.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/licenses/LICENSE +0 -0
{python_sendparcel_inpost-0.2.0.dist-info → python_sendparcel_inpost-0.3.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel-inpost
|
|
6
6
|
Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel-inpost
|
|
@@ -22,7 +22,7 @@ Classifier: Typing :: Typed
|
|
|
22
22
|
Requires-Python: >=3.12
|
|
23
23
|
Requires-Dist: anyio>=4.0
|
|
24
24
|
Requires-Dist: httpx>=0.27.0
|
|
25
|
-
Requires-Dist: python-sendparcel>=0.
|
|
25
|
+
Requires-Dist: python-sendparcel>=0.3.0
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
28
28
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
@@ -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,,
|
sendparcel_inpost/__init__.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
6
|
from sendparcel_inpost.exceptions import CircuitBreakerError
|
|
7
|
+
from sendparcel_inpost.providers.base import aclose_transports
|
|
7
8
|
from sendparcel_inpost.providers.courier import InPostCourierProvider
|
|
8
9
|
from sendparcel_inpost.providers.locker import InPostLockerProvider
|
|
9
10
|
|
|
@@ -13,4 +14,5 @@ __all__ = [
|
|
|
13
14
|
"InPostLockerProvider",
|
|
14
15
|
"ShipXClient",
|
|
15
16
|
"__version__",
|
|
17
|
+
"aclose_transports",
|
|
16
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
|
@@ -2,99 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
|
-
import atexit
|
|
7
5
|
import logging
|
|
8
|
-
import threading
|
|
9
6
|
import time
|
|
10
|
-
from dataclasses import dataclass, field
|
|
11
7
|
from types import TracebackType
|
|
12
8
|
from typing import Any, cast
|
|
13
9
|
|
|
10
|
+
import anyio
|
|
14
11
|
import httpx
|
|
15
12
|
|
|
13
|
+
from sendparcel_inpost.circuit_breaker import ShipXMetrics, _CircuitBreaker
|
|
16
14
|
from sendparcel_inpost.exceptions import (
|
|
17
15
|
CircuitBreakerError,
|
|
18
16
|
ShipXAPIError,
|
|
19
17
|
ShipXAuthenticationError,
|
|
20
18
|
ShipXValidationError,
|
|
21
19
|
)
|
|
20
|
+
from sendparcel_inpost.rate_limiter import _TokenBucket
|
|
22
21
|
|
|
23
22
|
logger = logging.getLogger(__name__)
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
@dataclass
|
|
27
|
-
class ShipXMetrics:
|
|
28
|
-
"""Collects metrics for a single ShipXClient instance.
|
|
29
|
-
|
|
30
|
-
Thread-safe. Use ``snapshot()`` to get a point-in-time view.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
34
|
-
_request_count: int = 0
|
|
35
|
-
_error_count: int = 0
|
|
36
|
-
_circuit_trip_count: int = 0
|
|
37
|
-
_retry_count: int = 0
|
|
38
|
-
_total_latency_ms: float = 0.0
|
|
39
|
-
_max_latency_ms: float = 0.0
|
|
40
|
-
_last_error: str | None = None
|
|
41
|
-
_last_error_at: float | None = None
|
|
42
|
-
_circuit_state: str = "closed"
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def circuit_state(self) -> str:
|
|
46
|
-
with self._lock:
|
|
47
|
-
return self._circuit_state
|
|
48
|
-
|
|
49
|
-
def set_circuit_state(self, state: str) -> None:
|
|
50
|
-
with self._lock:
|
|
51
|
-
self._circuit_state = state
|
|
52
|
-
|
|
53
|
-
def record_request(
|
|
54
|
-
self,
|
|
55
|
-
latency_ms: float,
|
|
56
|
-
success: bool,
|
|
57
|
-
error: str | None = None,
|
|
58
|
-
) -> None:
|
|
59
|
-
with self._lock:
|
|
60
|
-
self._request_count += 1
|
|
61
|
-
self._total_latency_ms += latency_ms
|
|
62
|
-
if latency_ms > self._max_latency_ms:
|
|
63
|
-
self._max_latency_ms = latency_ms
|
|
64
|
-
if not success:
|
|
65
|
-
self._error_count += 1
|
|
66
|
-
self._last_error = error
|
|
67
|
-
self._last_error_at = time.time()
|
|
68
|
-
|
|
69
|
-
def record_circuit_trip(self) -> None:
|
|
70
|
-
with self._lock:
|
|
71
|
-
self._circuit_trip_count += 1
|
|
72
|
-
|
|
73
|
-
def record_retry(self) -> None:
|
|
74
|
-
with self._lock:
|
|
75
|
-
self._retry_count += 1
|
|
76
|
-
|
|
77
|
-
def snapshot(self) -> dict[str, Any]:
|
|
78
|
-
"""Return a point-in-time copy of all metrics."""
|
|
79
|
-
with self._lock:
|
|
80
|
-
avg_latency = (
|
|
81
|
-
self._total_latency_ms / self._request_count
|
|
82
|
-
if self._request_count > 0
|
|
83
|
-
else 0.0
|
|
84
|
-
)
|
|
85
|
-
return {
|
|
86
|
-
"request_count": self._request_count,
|
|
87
|
-
"error_count": self._error_count,
|
|
88
|
-
"retry_count": self._retry_count,
|
|
89
|
-
"circuit_trip_count": self._circuit_trip_count,
|
|
90
|
-
"circuit_state": self._circuit_state,
|
|
91
|
-
"avg_latency_ms": round(avg_latency, 2),
|
|
92
|
-
"max_latency_ms": round(self._max_latency_ms, 2),
|
|
93
|
-
"last_error": self._last_error,
|
|
94
|
-
"last_error_at": self._last_error_at,
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
25
|
PRODUCTION_BASE_URL = "https://api-shipx-pl.easypack24.net"
|
|
99
26
|
SANDBOX_BASE_URL = "https://sandbox-api-shipx-pl.easypack24.net"
|
|
100
27
|
|
|
@@ -107,250 +34,6 @@ DEFAULT_CIRCUIT_THRESHOLD = 5 # consecutive failures to open circuit
|
|
|
107
34
|
DEFAULT_CIRCUIT_COOLDOWN = 30.0 # seconds before allowing probe request
|
|
108
35
|
|
|
109
36
|
|
|
110
|
-
class _TokenBucket:
|
|
111
|
-
"""Async token bucket rate limiter.
|
|
112
|
-
|
|
113
|
-
Tokens are added at a fixed rate up to a maximum burst size.
|
|
114
|
-
Each request consumes one token. If no tokens are available,
|
|
115
|
-
the caller waits until a token is refilled.
|
|
116
|
-
"""
|
|
117
|
-
|
|
118
|
-
__slots__ = ("_burst", "_last_refill", "_lock", "_rate", "_tokens")
|
|
119
|
-
|
|
120
|
-
def __init__(
|
|
121
|
-
self,
|
|
122
|
-
rate: float,
|
|
123
|
-
burst: int,
|
|
124
|
-
) -> None:
|
|
125
|
-
self._rate = rate
|
|
126
|
-
self._burst = burst
|
|
127
|
-
self._tokens = float(burst)
|
|
128
|
-
self._last_refill: float | None = None
|
|
129
|
-
self._lock = asyncio.Lock()
|
|
130
|
-
|
|
131
|
-
async def acquire(self) -> None:
|
|
132
|
-
"""Acquire a token, waiting if necessary.
|
|
133
|
-
|
|
134
|
-
Tokens are refilled based on elapsed wall-clock time up to the
|
|
135
|
-
burst limit. When no tokens are available the caller waits the
|
|
136
|
-
exact duration needed for one token to become available — sleep
|
|
137
|
-
time is NOT counted toward refill to prevent burst above limits.
|
|
138
|
-
"""
|
|
139
|
-
async with self._lock:
|
|
140
|
-
now = asyncio.get_event_loop().time()
|
|
141
|
-
if self._last_refill is None:
|
|
142
|
-
self._last_refill = now
|
|
143
|
-
elapsed = now - self._last_refill
|
|
144
|
-
self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
|
|
145
|
-
self._last_refill = now
|
|
146
|
-
|
|
147
|
-
if self._tokens < 1.0:
|
|
148
|
-
wait_time = (1.0 - self._tokens) / self._rate
|
|
149
|
-
await asyncio.sleep(wait_time)
|
|
150
|
-
# Consume the token and advance last_refill so that
|
|
151
|
-
# subsequent calls don't double-count the sleep.
|
|
152
|
-
self._last_refill = asyncio.get_event_loop().time()
|
|
153
|
-
self._tokens -= 1.0
|
|
154
|
-
else:
|
|
155
|
-
self._tokens -= 1.0
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
class _CircuitBreaker:
|
|
159
|
-
"""Simple circuit breaker for the ShipX API.
|
|
160
|
-
|
|
161
|
-
States:
|
|
162
|
-
- CLOSED: normal operation, requests pass through
|
|
163
|
-
- OPEN: circuit tripped, requests fail fast
|
|
164
|
-
- HALF_OPEN: one probe request allowed after cooldown
|
|
165
|
-
|
|
166
|
-
Transitions:
|
|
167
|
-
- CLOSED → OPEN: after `threshold` consecutive failures
|
|
168
|
-
- OPEN → HALF_OPEN: after `cooldown` seconds
|
|
169
|
-
- HALF_OPEN → CLOSED: probe request succeeds
|
|
170
|
-
- HALF_OPEN → OPEN: probe request fails
|
|
171
|
-
"""
|
|
172
|
-
|
|
173
|
-
class State:
|
|
174
|
-
CLOSED = "closed"
|
|
175
|
-
OPEN = "open"
|
|
176
|
-
HALF_OPEN = "half_open"
|
|
177
|
-
|
|
178
|
-
def __init__(
|
|
179
|
-
self,
|
|
180
|
-
threshold: int = 5,
|
|
181
|
-
cooldown: float = 30.0,
|
|
182
|
-
) -> None:
|
|
183
|
-
self._threshold = threshold
|
|
184
|
-
self._cooldown = cooldown
|
|
185
|
-
self._state = self.State.CLOSED
|
|
186
|
-
self._failures = 0
|
|
187
|
-
self._opened_at: float | None = None
|
|
188
|
-
|
|
189
|
-
@property
|
|
190
|
-
def state(self) -> str:
|
|
191
|
-
"""Current circuit state, transitioning OPEN → HALF_OPEN on cooldown."""
|
|
192
|
-
if self._state == self.State.OPEN and self._opened_at is not None:
|
|
193
|
-
elapsed = asyncio.get_event_loop().time() - self._opened_at
|
|
194
|
-
if elapsed >= self._cooldown:
|
|
195
|
-
self._state = self.State.HALF_OPEN
|
|
196
|
-
return self._state
|
|
197
|
-
|
|
198
|
-
@property
|
|
199
|
-
def failures(self) -> int:
|
|
200
|
-
return self._failures
|
|
201
|
-
|
|
202
|
-
@property
|
|
203
|
-
def cooldown_remaining(self) -> float:
|
|
204
|
-
if self._state != self.State.OPEN or self._opened_at is None:
|
|
205
|
-
return 0.0
|
|
206
|
-
elapsed = asyncio.get_event_loop().time() - self._opened_at
|
|
207
|
-
return max(0.0, self._cooldown - elapsed)
|
|
208
|
-
|
|
209
|
-
def allow_request(self) -> bool:
|
|
210
|
-
"""Check if a request should be allowed through."""
|
|
211
|
-
current = self.state # triggers OPEN → HALF_OPEN transition
|
|
212
|
-
return current != self.State.OPEN
|
|
213
|
-
|
|
214
|
-
def record_success(self) -> None:
|
|
215
|
-
"""Record a successful request."""
|
|
216
|
-
self._failures = 0
|
|
217
|
-
self._state = self.State.CLOSED
|
|
218
|
-
self._opened_at = None
|
|
219
|
-
|
|
220
|
-
def record_failure(self) -> None:
|
|
221
|
-
"""Record a failed request."""
|
|
222
|
-
self._failures += 1
|
|
223
|
-
if self.state == self.State.HALF_OPEN:
|
|
224
|
-
# Probe failed — reopen circuit
|
|
225
|
-
self._state = self.State.OPEN
|
|
226
|
-
self._opened_at = asyncio.get_event_loop().time()
|
|
227
|
-
elif self._failures >= self._threshold:
|
|
228
|
-
# Threshold reached — open circuit
|
|
229
|
-
self._state = self.State.OPEN
|
|
230
|
-
self._opened_at = asyncio.get_event_loop().time()
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
class _ClientPool:
|
|
234
|
-
"""Thread-safe pool of shared ShipXClient instances.
|
|
235
|
-
|
|
236
|
-
Clients are keyed by (token, organization_id, sandbox, base_url,
|
|
237
|
-
timeout, rate_limit, burst, max_retries, retry_base_delay,
|
|
238
|
-
circuit_threshold, circuit_cooldown).
|
|
239
|
-
Multiple providers with the same credentials share a single client.
|
|
240
|
-
"""
|
|
241
|
-
|
|
242
|
-
def __init__(self) -> None:
|
|
243
|
-
self._clients: dict[
|
|
244
|
-
tuple[
|
|
245
|
-
str,
|
|
246
|
-
int,
|
|
247
|
-
bool,
|
|
248
|
-
str | None,
|
|
249
|
-
float,
|
|
250
|
-
float,
|
|
251
|
-
int,
|
|
252
|
-
int,
|
|
253
|
-
float,
|
|
254
|
-
int,
|
|
255
|
-
float,
|
|
256
|
-
],
|
|
257
|
-
ShipXClient,
|
|
258
|
-
] = {}
|
|
259
|
-
self._lock = threading.Lock()
|
|
260
|
-
|
|
261
|
-
def get(
|
|
262
|
-
self,
|
|
263
|
-
token: str,
|
|
264
|
-
organization_id: int,
|
|
265
|
-
*,
|
|
266
|
-
sandbox: bool = False,
|
|
267
|
-
base_url: str | None = None,
|
|
268
|
-
timeout: float = DEFAULT_TIMEOUT,
|
|
269
|
-
rate_limit: float = DEFAULT_RATE_LIMIT,
|
|
270
|
-
burst: int = DEFAULT_BURST,
|
|
271
|
-
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
272
|
-
retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
|
|
273
|
-
circuit_threshold: int = DEFAULT_CIRCUIT_THRESHOLD,
|
|
274
|
-
circuit_cooldown: float = DEFAULT_CIRCUIT_COOLDOWN,
|
|
275
|
-
) -> ShipXClient:
|
|
276
|
-
"""Get or create a shared client for the given credentials.
|
|
277
|
-
|
|
278
|
-
The pool key includes all parameters that affect client behavior,
|
|
279
|
-
ensuring that clients with different rate limits, burst sizes,
|
|
280
|
-
circuit breaker settings, or retry policies are cached separately.
|
|
281
|
-
"""
|
|
282
|
-
key = (
|
|
283
|
-
token,
|
|
284
|
-
organization_id,
|
|
285
|
-
sandbox,
|
|
286
|
-
base_url,
|
|
287
|
-
timeout,
|
|
288
|
-
rate_limit,
|
|
289
|
-
burst,
|
|
290
|
-
max_retries,
|
|
291
|
-
retry_base_delay,
|
|
292
|
-
circuit_threshold,
|
|
293
|
-
circuit_cooldown,
|
|
294
|
-
)
|
|
295
|
-
with self._lock:
|
|
296
|
-
client = self._clients.get(key)
|
|
297
|
-
if client is None:
|
|
298
|
-
client = ShipXClient(
|
|
299
|
-
token=token,
|
|
300
|
-
organization_id=organization_id,
|
|
301
|
-
sandbox=sandbox,
|
|
302
|
-
base_url=base_url,
|
|
303
|
-
timeout=timeout,
|
|
304
|
-
rate_limit=rate_limit,
|
|
305
|
-
burst=burst,
|
|
306
|
-
max_retries=max_retries,
|
|
307
|
-
retry_base_delay=retry_base_delay,
|
|
308
|
-
circuit_threshold=circuit_threshold,
|
|
309
|
-
circuit_cooldown=circuit_cooldown,
|
|
310
|
-
)
|
|
311
|
-
self._clients[key] = client
|
|
312
|
-
return client
|
|
313
|
-
|
|
314
|
-
async def close_all(self) -> None:
|
|
315
|
-
"""Close all pooled clients.
|
|
316
|
-
|
|
317
|
-
Must be awaited from an async context (e.g. an ``atexit`` hook
|
|
318
|
-
running via ``anyio.run``, or a Django ASGI shutdown signal).
|
|
319
|
-
"""
|
|
320
|
-
clients: list[ShipXClient] = []
|
|
321
|
-
with self._lock:
|
|
322
|
-
clients = list(self._clients.values())
|
|
323
|
-
self._clients.clear()
|
|
324
|
-
await asyncio.gather(
|
|
325
|
-
*(c.close() for c in clients), return_exceptions=True
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
# Module-level singleton pool.
|
|
330
|
-
_pool = _ClientPool()
|
|
331
|
-
|
|
332
|
-
# Shutdown hook — registered at import time so it fires on process exit.
|
|
333
|
-
# Uses anyio.run to create a temporary event loop for cleanup.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _shutdown_pool() -> None:
|
|
337
|
-
"""Clean up pooled HTTP clients on process exit."""
|
|
338
|
-
import anyio
|
|
339
|
-
|
|
340
|
-
try:
|
|
341
|
-
anyio.run(_pool.close_all)
|
|
342
|
-
except Exception:
|
|
343
|
-
logger.warning("Failed to close client pool on shutdown", exc_info=True)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
atexit.register(_shutdown_pool)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def get_client_pool() -> _ClientPool:
|
|
350
|
-
"""Return the module-level client pool singleton."""
|
|
351
|
-
return _pool
|
|
352
|
-
|
|
353
|
-
|
|
354
37
|
class ShipXClient:
|
|
355
38
|
"""Async HTTP client for InPost ShipX API.
|
|
356
39
|
|
|
@@ -451,6 +134,7 @@ class ShipXClient:
|
|
|
451
134
|
"""
|
|
452
135
|
start = time.monotonic()
|
|
453
136
|
retries = 0
|
|
137
|
+
op_name = getattr(coro, "__qualname__", repr(coro))
|
|
454
138
|
|
|
455
139
|
# Check circuit breaker before attempting any work
|
|
456
140
|
if not self._circuit.allow_request():
|
|
@@ -502,11 +186,11 @@ class ShipXClient:
|
|
|
502
186
|
"Retry %d/%d for %s after %.1fs: %s",
|
|
503
187
|
attempt + 1,
|
|
504
188
|
self._max_retries,
|
|
505
|
-
|
|
189
|
+
op_name,
|
|
506
190
|
delay,
|
|
507
191
|
exc,
|
|
508
192
|
)
|
|
509
|
-
await
|
|
193
|
+
await anyio.sleep(delay)
|
|
510
194
|
else:
|
|
511
195
|
latency_ms = (time.monotonic() - start) * 1000
|
|
512
196
|
self._metrics.record_request(
|
|
@@ -515,59 +199,36 @@ class ShipXClient:
|
|
|
515
199
|
error=f"{type(exc).__name__}: {exc}",
|
|
516
200
|
)
|
|
517
201
|
raise
|
|
518
|
-
except httpx.HTTPStatusError as exc:
|
|
519
|
-
if exc.response.status_code >= 500 and idempotent:
|
|
520
|
-
last_exc = exc
|
|
521
|
-
self._circuit.record_failure()
|
|
522
|
-
self._metrics.set_circuit_state(self._circuit.state)
|
|
523
|
-
if attempt < self._max_retries:
|
|
524
|
-
retries += 1
|
|
525
|
-
self._metrics.record_retry()
|
|
526
|
-
delay = self._retry_base_delay * (2**attempt)
|
|
527
|
-
logger.warning(
|
|
528
|
-
"Retry %d/%d for %s after %.1fs: HTTP %d",
|
|
529
|
-
attempt + 1,
|
|
530
|
-
self._max_retries,
|
|
531
|
-
type(coro).__name__,
|
|
532
|
-
delay,
|
|
533
|
-
exc.response.status_code,
|
|
534
|
-
)
|
|
535
|
-
await asyncio.sleep(delay)
|
|
536
|
-
else:
|
|
537
|
-
latency_ms = (time.monotonic() - start) * 1000
|
|
538
|
-
self._metrics.record_request(
|
|
539
|
-
latency_ms,
|
|
540
|
-
success=False,
|
|
541
|
-
error=f"HTTP {exc.response.status_code}",
|
|
542
|
-
)
|
|
543
|
-
raise
|
|
544
|
-
else:
|
|
545
|
-
latency_ms = (time.monotonic() - start) * 1000
|
|
546
|
-
self._metrics.record_request(
|
|
547
|
-
latency_ms,
|
|
548
|
-
success=False,
|
|
549
|
-
error=f"HTTP {exc.response.status_code}",
|
|
550
|
-
)
|
|
551
|
-
raise
|
|
552
202
|
except ShipXAPIError as exc:
|
|
553
|
-
|
|
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:
|
|
554
212
|
last_exc = exc
|
|
555
|
-
|
|
556
|
-
|
|
213
|
+
if not rate_limited:
|
|
214
|
+
self._circuit.record_failure()
|
|
215
|
+
self._metrics.set_circuit_state(self._circuit.state)
|
|
557
216
|
if attempt < self._max_retries:
|
|
558
217
|
retries += 1
|
|
559
218
|
self._metrics.record_retry()
|
|
560
219
|
delay = self._retry_base_delay * (2**attempt)
|
|
220
|
+
if rate_limited and exc.retry_after is not None:
|
|
221
|
+
delay = exc.retry_after
|
|
561
222
|
logger.warning(
|
|
562
223
|
"Retry %d/%d for %s after %.1fs: HTTP %d %s",
|
|
563
224
|
attempt + 1,
|
|
564
225
|
self._max_retries,
|
|
565
|
-
|
|
226
|
+
op_name,
|
|
566
227
|
delay,
|
|
567
228
|
exc.status_code,
|
|
568
229
|
exc.detail,
|
|
569
230
|
)
|
|
570
|
-
await
|
|
231
|
+
await anyio.sleep(delay)
|
|
571
232
|
else:
|
|
572
233
|
latency_ms = (time.monotonic() - start) * 1000
|
|
573
234
|
self._metrics.record_request(
|
|
@@ -748,4 +409,17 @@ class ShipXClient:
|
|
|
748
409
|
status_code=status_code,
|
|
749
410
|
detail=str(detail),
|
|
750
411
|
errors=errors,
|
|
412
|
+
retry_after=_parse_retry_after(response),
|
|
751
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
|
sendparcel_inpost/exceptions.py
CHANGED
|
@@ -13,10 +13,12 @@ class ShipXAPIError(CommunicationError):
|
|
|
13
13
|
status_code: int,
|
|
14
14
|
detail: str,
|
|
15
15
|
errors: list[dict[str, Any]] | None = None,
|
|
16
|
+
retry_after: float | None = None,
|
|
16
17
|
) -> None:
|
|
17
18
|
self.status_code = status_code
|
|
18
19
|
self.detail = detail
|
|
19
20
|
self.errors = errors or []
|
|
21
|
+
self.retry_after = retry_after
|
|
20
22
|
super().__init__(
|
|
21
23
|
f"ShipX API error {status_code}: {detail}",
|
|
22
24
|
context={
|
|
@@ -7,26 +7,31 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import base64
|
|
9
9
|
import ipaddress
|
|
10
|
+
import threading
|
|
10
11
|
from typing import Any, ClassVar
|
|
11
12
|
|
|
12
13
|
from sendparcel.enums import ConfirmationMethod, LabelFormat
|
|
13
14
|
from sendparcel.exceptions import InvalidCallbackError
|
|
14
15
|
from sendparcel.logging import get_logger
|
|
15
16
|
from sendparcel.protocols import Shipment
|
|
16
|
-
from sendparcel.provider import
|
|
17
|
-
BaseProvider,
|
|
18
|
-
CancellableProvider,
|
|
19
|
-
LabelProvider,
|
|
20
|
-
PullStatusProvider,
|
|
21
|
-
PushCallbackProvider,
|
|
22
|
-
)
|
|
17
|
+
from sendparcel.provider import BaseProvider
|
|
23
18
|
from sendparcel.types import (
|
|
24
19
|
AddressInfo,
|
|
20
|
+
CallbackContext,
|
|
25
21
|
LabelInfo,
|
|
26
22
|
ShipmentUpdateResult,
|
|
27
23
|
)
|
|
28
24
|
|
|
29
|
-
from sendparcel_inpost.client import
|
|
25
|
+
from sendparcel_inpost.client import (
|
|
26
|
+
DEFAULT_BURST,
|
|
27
|
+
DEFAULT_CIRCUIT_COOLDOWN,
|
|
28
|
+
DEFAULT_CIRCUIT_THRESHOLD,
|
|
29
|
+
DEFAULT_MAX_RETRIES,
|
|
30
|
+
DEFAULT_RATE_LIMIT,
|
|
31
|
+
DEFAULT_RETRY_BASE_DELAY,
|
|
32
|
+
DEFAULT_TIMEOUT,
|
|
33
|
+
ShipXClient,
|
|
34
|
+
)
|
|
30
35
|
from sendparcel_inpost.exceptions import (
|
|
31
36
|
ShipXAPIError,
|
|
32
37
|
ShipXAuthenticationError,
|
|
@@ -39,13 +44,65 @@ logger = get_logger(__name__)
|
|
|
39
44
|
_DEFAULT_WEBHOOK_NETWORKS: list[str] = ["91.216.25.0/24"]
|
|
40
45
|
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
47
|
+
# Shared clients keyed by client-relevant config. The flow builds a
|
|
48
|
+
# provider (and calls the transport factory) on every operation, so the
|
|
49
|
+
# factory must reuse clients: a fresh httpx.AsyncClient per call would
|
|
50
|
+
# leak connections and reset the circuit breaker, rate limiter, and
|
|
51
|
+
# metrics on each operation.
|
|
52
|
+
_transport_cache: dict[tuple[Any, ...], ShipXClient] = {}
|
|
53
|
+
_transport_cache_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _client_kwargs(config: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Extract ShipXClient constructor kwargs from provider config."""
|
|
58
|
+
return {
|
|
59
|
+
"token": config.get("token", ""),
|
|
60
|
+
"organization_id": config.get("organization_id", 0),
|
|
61
|
+
"sandbox": config.get("sandbox", False),
|
|
62
|
+
"base_url": config.get("base_url"),
|
|
63
|
+
"timeout": config.get("timeout", DEFAULT_TIMEOUT),
|
|
64
|
+
"rate_limit": config.get("rate_limit", DEFAULT_RATE_LIMIT),
|
|
65
|
+
"burst": config.get("burst", DEFAULT_BURST),
|
|
66
|
+
"max_retries": config.get("max_retries", DEFAULT_MAX_RETRIES),
|
|
67
|
+
"retry_base_delay": config.get(
|
|
68
|
+
"retry_base_delay", DEFAULT_RETRY_BASE_DELAY
|
|
69
|
+
),
|
|
70
|
+
"circuit_threshold": config.get(
|
|
71
|
+
"circuit_threshold", DEFAULT_CIRCUIT_THRESHOLD
|
|
72
|
+
),
|
|
73
|
+
"circuit_cooldown": config.get(
|
|
74
|
+
"circuit_cooldown", DEFAULT_CIRCUIT_COOLDOWN
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_inpost_transport(**config: Any) -> ShipXClient:
|
|
80
|
+
"""Return the shared ShipXClient for this provider config."""
|
|
81
|
+
kwargs = _client_kwargs(config)
|
|
82
|
+
key = tuple(sorted(kwargs.items(), key=lambda kv: kv[0]))
|
|
83
|
+
with _transport_cache_lock:
|
|
84
|
+
client = _transport_cache.get(key)
|
|
85
|
+
if client is None:
|
|
86
|
+
client = ShipXClient(**kwargs)
|
|
87
|
+
_transport_cache[key] = client
|
|
88
|
+
return client
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def aclose_transports() -> None:
|
|
92
|
+
"""Close and evict all shared ShipX clients.
|
|
93
|
+
|
|
94
|
+
Call during application shutdown to release HTTP connections.
|
|
95
|
+
Safe to call multiple times; subsequent factory calls create
|
|
96
|
+
fresh clients.
|
|
97
|
+
"""
|
|
98
|
+
with _transport_cache_lock:
|
|
99
|
+
clients = list(_transport_cache.values())
|
|
100
|
+
_transport_cache.clear()
|
|
101
|
+
for client in clients:
|
|
102
|
+
await client.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InPostBaseProvider(BaseProvider):
|
|
49
106
|
"""Shared implementation for InPost courier and locker providers.
|
|
50
107
|
|
|
51
108
|
Subclasses must define:
|
|
@@ -61,6 +118,7 @@ class InPostBaseProvider(
|
|
|
61
118
|
supported_countries: ClassVar[list[str]] = ["PL"]
|
|
62
119
|
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
|
|
63
120
|
user_selectable: ClassVar[bool] = True
|
|
121
|
+
transport_factory: ClassVar[Any] = staticmethod(_build_inpost_transport)
|
|
64
122
|
config_schema: ClassVar[dict[str, Any]] = {
|
|
65
123
|
"token": {
|
|
66
124
|
"type": "str",
|
|
@@ -94,6 +152,48 @@ class InPostBaseProvider(
|
|
|
94
152
|
"description": "HTTP request timeout in seconds",
|
|
95
153
|
"default": 30.0,
|
|
96
154
|
},
|
|
155
|
+
"rate_limit": {
|
|
156
|
+
"type": "float",
|
|
157
|
+
"required": False,
|
|
158
|
+
"secret": False,
|
|
159
|
+
"description": "Max requests per second to the ShipX API",
|
|
160
|
+
"default": DEFAULT_RATE_LIMIT,
|
|
161
|
+
},
|
|
162
|
+
"burst": {
|
|
163
|
+
"type": "int",
|
|
164
|
+
"required": False,
|
|
165
|
+
"secret": False,
|
|
166
|
+
"description": "Rate limiter burst size",
|
|
167
|
+
"default": DEFAULT_BURST,
|
|
168
|
+
},
|
|
169
|
+
"max_retries": {
|
|
170
|
+
"type": "int",
|
|
171
|
+
"required": False,
|
|
172
|
+
"secret": False,
|
|
173
|
+
"description": "Max retries for transient failures",
|
|
174
|
+
"default": DEFAULT_MAX_RETRIES,
|
|
175
|
+
},
|
|
176
|
+
"retry_base_delay": {
|
|
177
|
+
"type": "float",
|
|
178
|
+
"required": False,
|
|
179
|
+
"secret": False,
|
|
180
|
+
"description": "Base delay for exponential backoff (seconds)",
|
|
181
|
+
"default": DEFAULT_RETRY_BASE_DELAY,
|
|
182
|
+
},
|
|
183
|
+
"circuit_threshold": {
|
|
184
|
+
"type": "int",
|
|
185
|
+
"required": False,
|
|
186
|
+
"secret": False,
|
|
187
|
+
"description": "Consecutive failures before the circuit opens",
|
|
188
|
+
"default": DEFAULT_CIRCUIT_THRESHOLD,
|
|
189
|
+
},
|
|
190
|
+
"circuit_cooldown": {
|
|
191
|
+
"type": "float",
|
|
192
|
+
"required": False,
|
|
193
|
+
"secret": False,
|
|
194
|
+
"description": "Seconds before the open circuit allows a probe",
|
|
195
|
+
"default": DEFAULT_CIRCUIT_COOLDOWN,
|
|
196
|
+
},
|
|
97
197
|
"trusted_networks": {
|
|
98
198
|
"type": "list[str]",
|
|
99
199
|
"required": False,
|
|
@@ -116,57 +216,11 @@ class InPostBaseProvider(
|
|
|
116
216
|
self,
|
|
117
217
|
shipment: Shipment,
|
|
118
218
|
config: dict[str, Any] | None = None,
|
|
219
|
+
*,
|
|
220
|
+
transport: Any = None,
|
|
119
221
|
) -> None:
|
|
120
|
-
super().__init__(shipment, config)
|
|
121
|
-
self.
|
|
122
|
-
self._client = self._build_client()
|
|
123
|
-
|
|
124
|
-
def _validate_config(self) -> None:
|
|
125
|
-
"""Validate configuration against ``config_schema``.
|
|
126
|
-
|
|
127
|
-
Checks that all required fields are present and have the
|
|
128
|
-
correct type. Raises ``ValueError`` with a helpful message
|
|
129
|
-
on the first violation found.
|
|
130
|
-
"""
|
|
131
|
-
for field_name, spec in self.config_schema.items():
|
|
132
|
-
if not spec.get("required", False):
|
|
133
|
-
continue
|
|
134
|
-
value = self.get_setting(field_name)
|
|
135
|
-
if value is None or value == "":
|
|
136
|
-
raise ValueError(
|
|
137
|
-
f"InPost provider requires '{field_name}' in config. "
|
|
138
|
-
f"Set {field_name!r} in your provider configuration."
|
|
139
|
-
)
|
|
140
|
-
expected_type = spec.get("type")
|
|
141
|
-
if expected_type and value is not None:
|
|
142
|
-
type_map: dict[str, type | tuple[type, ...]] = {
|
|
143
|
-
"str": str,
|
|
144
|
-
"int": int,
|
|
145
|
-
"float": (int, float),
|
|
146
|
-
"bool": bool,
|
|
147
|
-
"list[str]": list,
|
|
148
|
-
}
|
|
149
|
-
python_type = type_map.get(expected_type)
|
|
150
|
-
if python_type and not isinstance(value, python_type):
|
|
151
|
-
raise TypeError(
|
|
152
|
-
f"InPost provider config '{field_name}' must be "
|
|
153
|
-
f"{expected_type}, got {type(value).__name__}"
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
def _build_client(self) -> ShipXClient:
|
|
157
|
-
"""Build a ShipXClient from provider config using the shared pool."""
|
|
158
|
-
pool = get_client_pool()
|
|
159
|
-
return pool.get(
|
|
160
|
-
token=self.get_setting("token", ""),
|
|
161
|
-
organization_id=self.get_setting("organization_id", 0),
|
|
162
|
-
sandbox=self.get_setting("sandbox", False),
|
|
163
|
-
base_url=self.get_setting("base_url"),
|
|
164
|
-
timeout=self.get_setting("timeout", 30.0),
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
def _get_client(self) -> ShipXClient:
|
|
168
|
-
"""Return the shared ShipXClient instance."""
|
|
169
|
-
return self._client
|
|
222
|
+
super().__init__(shipment, config, transport=transport)
|
|
223
|
+
self._client = transport
|
|
170
224
|
|
|
171
225
|
def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
|
|
172
226
|
"""Convert sendparcel AddressInfo to ShipX peer dict."""
|
|
@@ -243,23 +297,17 @@ class InPostBaseProvider(
|
|
|
243
297
|
|
|
244
298
|
async def verify_callback(
|
|
245
299
|
self,
|
|
246
|
-
|
|
247
|
-
headers: dict[str, Any],
|
|
248
|
-
source_ip: str | None = None,
|
|
300
|
+
ctx: CallbackContext,
|
|
249
301
|
**kwargs: Any,
|
|
250
302
|
) -> None:
|
|
251
303
|
"""Verify InPost webhook by validated source IP.
|
|
252
304
|
|
|
253
305
|
The provider checks the source IP against the configured
|
|
254
|
-
``trusted_networks`` setting. The IP
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
FastAPI/Litestar or ``request.META['REMOTE_ADDR']`` in Django).
|
|
260
|
-
2. **Header fallback**: if ``source_ip`` is not provided, the
|
|
261
|
-
provider falls back to the ``x-validated-source-ip`` header,
|
|
262
|
-
which the Django view sets from ``REMOTE_ADDR``.
|
|
306
|
+
``trusted_networks`` setting. The IP is read from the
|
|
307
|
+
``CallbackContext.source_ip`` field, which the framework
|
|
308
|
+
layer populates from the actual TCP connection
|
|
309
|
+
(``request.META['REMOTE_ADDR']`` in Django, ``request.client.host``
|
|
310
|
+
in FastAPI/Litestar).
|
|
263
311
|
|
|
264
312
|
Verification can be disabled entirely with
|
|
265
313
|
``verify_webhook_ip=False`` (useful for local development).
|
|
@@ -267,12 +315,11 @@ class InPostBaseProvider(
|
|
|
267
315
|
if not self.get_setting("verify_webhook_ip", True):
|
|
268
316
|
return
|
|
269
317
|
|
|
270
|
-
|
|
271
|
-
ip_str = source_ip or headers.get("x-validated-source-ip", "")
|
|
318
|
+
ip_str = ctx.source_ip
|
|
272
319
|
if not ip_str:
|
|
273
320
|
raise InvalidCallbackError(
|
|
274
|
-
"Missing source IP;
|
|
275
|
-
"
|
|
321
|
+
"Missing source IP; ensure the framework layer populates "
|
|
322
|
+
"CallbackContext.source_ip from the TCP connection"
|
|
276
323
|
)
|
|
277
324
|
await self.validate_source_ip(ip_str)
|
|
278
325
|
|
|
@@ -300,12 +347,11 @@ class InPostBaseProvider(
|
|
|
300
347
|
|
|
301
348
|
async def handle_callback(
|
|
302
349
|
self,
|
|
303
|
-
|
|
304
|
-
headers: dict[str, Any],
|
|
350
|
+
ctx: CallbackContext,
|
|
305
351
|
**kwargs: Any,
|
|
306
352
|
) -> ShipmentUpdateResult:
|
|
307
353
|
"""Process InPost webhook payload."""
|
|
308
|
-
payload =
|
|
354
|
+
payload = ctx.payload.get("payload", {})
|
|
309
355
|
shipx_status = str(payload.get("status", ""))
|
|
310
356
|
tracking_number = str(payload.get("tracking_number", ""))
|
|
311
357
|
update = build_shipment_update(
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any, ClassVar
|
|
6
6
|
|
|
7
7
|
from sendparcel.enums import ConfirmationMethod
|
|
8
|
-
from sendparcel.protocols import Shipment
|
|
9
8
|
from sendparcel.types import (
|
|
10
9
|
AddressInfo,
|
|
11
10
|
ParcelInfo,
|
|
@@ -27,13 +26,6 @@ class InPostCourierProvider(
|
|
|
27
26
|
]
|
|
28
27
|
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
|
|
29
28
|
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
shipment: Shipment,
|
|
33
|
-
config: dict[str, Any] | None = None,
|
|
34
|
-
) -> None:
|
|
35
|
-
super().__init__(shipment, config)
|
|
36
|
-
|
|
37
29
|
async def create_shipment(
|
|
38
30
|
self,
|
|
39
31
|
*,
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any, ClassVar
|
|
6
6
|
|
|
7
7
|
from sendparcel.enums import ConfirmationMethod
|
|
8
|
-
from sendparcel.protocols import Shipment
|
|
9
8
|
from sendparcel.types import (
|
|
10
9
|
AddressInfo,
|
|
11
10
|
ParcelInfo,
|
|
@@ -27,13 +26,6 @@ class InPostLockerProvider(
|
|
|
27
26
|
]
|
|
28
27
|
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
|
|
29
28
|
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
shipment: Shipment,
|
|
33
|
-
config: dict[str, Any] | None = None,
|
|
34
|
-
) -> None:
|
|
35
|
-
super().__init__(shipment, config)
|
|
36
|
-
|
|
37
29
|
def _parcel_template_from_parcels(self, parcels: list[ParcelInfo]) -> str:
|
|
38
30
|
"""Determine locker parcel template from parcels.
|
|
39
31
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Async token bucket rate limiter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _TokenBucket:
|
|
11
|
+
"""Async token bucket rate limiter.
|
|
12
|
+
|
|
13
|
+
Tokens are added at a fixed rate up to a maximum burst size.
|
|
14
|
+
Each request consumes one token. If no tokens are available,
|
|
15
|
+
the caller waits until a token is refilled.
|
|
16
|
+
|
|
17
|
+
Backend-agnostic (anyio): works under both asyncio and trio.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("_burst", "_last_refill", "_lock", "_rate", "_tokens")
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
rate: float,
|
|
25
|
+
burst: int,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._rate = rate
|
|
28
|
+
self._burst = burst
|
|
29
|
+
self._tokens = float(burst)
|
|
30
|
+
self._last_refill: float | None = None
|
|
31
|
+
self._lock = anyio.Lock()
|
|
32
|
+
|
|
33
|
+
async def acquire(self) -> None:
|
|
34
|
+
"""Acquire a token, waiting if necessary.
|
|
35
|
+
|
|
36
|
+
Tokens are refilled based on elapsed wall-clock time up to the
|
|
37
|
+
burst limit. When no tokens are available the caller waits the
|
|
38
|
+
exact duration needed for one token to become available — sleep
|
|
39
|
+
time is NOT counted toward refill to prevent burst above limits.
|
|
40
|
+
"""
|
|
41
|
+
async with self._lock:
|
|
42
|
+
now = time.monotonic()
|
|
43
|
+
if self._last_refill is None:
|
|
44
|
+
self._last_refill = now
|
|
45
|
+
elapsed = now - self._last_refill
|
|
46
|
+
self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
|
|
47
|
+
self._last_refill = now
|
|
48
|
+
|
|
49
|
+
if self._tokens < 1.0:
|
|
50
|
+
wait_time = (1.0 - self._tokens) / self._rate
|
|
51
|
+
await anyio.sleep(wait_time)
|
|
52
|
+
# Consume the token and advance last_refill so that
|
|
53
|
+
# subsequent calls don't double-count the sleep.
|
|
54
|
+
self._last_refill = time.monotonic()
|
|
55
|
+
self._tokens -= 1.0
|
|
56
|
+
else:
|
|
57
|
+
self._tokens -= 1.0
|
|
@@ -3,34 +3,33 @@
|
|
|
3
3
|
from sendparcel.enums import ShipmentStatus
|
|
4
4
|
from sendparcel.types import ShipmentUpdateResult
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
# Non-trivial mappings only: ShipX status strings that don't match the
|
|
7
|
+
# ShipmentStatus enum value. Trivial cases (e.g. "created" → CREATED)
|
|
8
|
+
# are derived from the enum in map_shipx_status().
|
|
9
|
+
_NON_TRIVIAL_STATUS_MAP: dict[str, ShipmentStatus] = {
|
|
10
|
+
# → CREATED
|
|
9
11
|
"offers_prepared": ShipmentStatus.CREATED,
|
|
10
12
|
"offer_selected": ShipmentStatus.CREATED,
|
|
11
|
-
# LABEL_READY
|
|
13
|
+
# → LABEL_READY
|
|
12
14
|
"confirmed": ShipmentStatus.LABEL_READY,
|
|
13
|
-
# IN_TRANSIT
|
|
15
|
+
# → IN_TRANSIT
|
|
14
16
|
"dispatched_by_sender": ShipmentStatus.IN_TRANSIT,
|
|
15
17
|
"collected_from_sender": ShipmentStatus.IN_TRANSIT,
|
|
16
18
|
"taken_by_courier": ShipmentStatus.IN_TRANSIT,
|
|
17
19
|
"adopted_at_source_branch": ShipmentStatus.IN_TRANSIT,
|
|
18
20
|
"sent_from_source_branch": ShipmentStatus.IN_TRANSIT,
|
|
19
21
|
"adopted_at_sorting_center": ShipmentStatus.IN_TRANSIT,
|
|
20
|
-
# OUT_FOR_DELIVERY
|
|
21
|
-
"out_for_delivery": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
22
|
+
# → OUT_FOR_DELIVERY
|
|
22
23
|
"ready_to_pickup": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
23
24
|
"pickup_reminder_sent": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
24
25
|
"avizo": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
25
26
|
"stack_in_box_machine": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
26
27
|
"stack_in_customer_service_point": ShipmentStatus.OUT_FOR_DELIVERY,
|
|
27
|
-
#
|
|
28
|
-
"delivered": ShipmentStatus.DELIVERED,
|
|
29
|
-
# CANCELLED
|
|
28
|
+
# → CANCELLED (ShipX uses US spelling "canceled")
|
|
30
29
|
"canceled": ShipmentStatus.CANCELLED,
|
|
31
|
-
# RETURNED
|
|
30
|
+
# → RETURNED
|
|
32
31
|
"returned_to_sender": ShipmentStatus.RETURNED,
|
|
33
|
-
# FAILED
|
|
32
|
+
# → FAILED
|
|
34
33
|
"rejected_by_receiver": ShipmentStatus.FAILED,
|
|
35
34
|
"undelivered": ShipmentStatus.FAILED,
|
|
36
35
|
"oversized": ShipmentStatus.FAILED,
|
|
@@ -42,9 +41,20 @@ SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
|
|
|
42
41
|
def map_shipx_status(shipx_status: str) -> ShipmentStatus | None:
|
|
43
42
|
"""Map a ShipX status string to a sendparcel ShipmentStatus.
|
|
44
43
|
|
|
44
|
+
Trivial mappings (where the ShipX string matches the enum value) are
|
|
45
|
+
derived from the ShipmentStatus enum. Non-trivial mappings are looked
|
|
46
|
+
up in the explicit exceptions dict.
|
|
47
|
+
|
|
45
48
|
Returns None if the status is not recognized.
|
|
46
49
|
"""
|
|
47
|
-
|
|
50
|
+
# Check non-trivial mappings first.
|
|
51
|
+
if shipx_status in _NON_TRIVIAL_STATUS_MAP:
|
|
52
|
+
return _NON_TRIVIAL_STATUS_MAP[shipx_status]
|
|
53
|
+
# Try trivial mapping: ShipX status string == enum value.
|
|
54
|
+
try:
|
|
55
|
+
return ShipmentStatus(shipx_status)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
48
58
|
|
|
49
59
|
|
|
50
60
|
def build_shipment_update(
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
sendparcel_inpost/__init__.py,sha256=iF669tt96Ux4fW-DD2XAIiYEX7_F36gFGxZ9Mui-nzA,460
|
|
2
|
-
sendparcel_inpost/client.py,sha256=wxoCH-7FQc7zdqE2xEXDyBLt-DSdKPHN4s1opTj6YvQ,25607
|
|
3
|
-
sendparcel_inpost/enums.py,sha256=8R0BPxTc_PQdPTjYXMIFlFjNju4X6hL8Xq3EEUe5J94,384
|
|
4
|
-
sendparcel_inpost/exceptions.py,sha256=JRCsLiqLU_u96aVVa5xz9a4MAVBxB6CqR6g_83YyD44,2061
|
|
5
|
-
sendparcel_inpost/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
sendparcel_inpost/status_mapping.py,sha256=XPmp-92CUOidLY1EPeL7d0PGDEsqgRAJxK3247y5zWk,2251
|
|
7
|
-
sendparcel_inpost/types.py,sha256=Pu-OJpC6te4_gY6PLv-Ag0o2IoulYytdr9t0FGf4GIA,1426
|
|
8
|
-
sendparcel_inpost/providers/__init__.py,sha256=akn431q6mekenZV5Y4wjCh2UkLSi2s51umBHZ37orJg,336
|
|
9
|
-
sendparcel_inpost/providers/base.py,sha256=dNxSTiE-BrJCCEmTlMVOuriLqpKu1wkMtM8dQspu5C4,12613
|
|
10
|
-
sendparcel_inpost/providers/courier.py,sha256=N-lGvL_6R6wq0nnJUum8wvMwKLjDric8Vv4xODWm5Rg,2799
|
|
11
|
-
sendparcel_inpost/providers/locker.py,sha256=1DG5nPcJGNlJP-ul87U_VBZkvt4VZXKhIws54kYTRe0,3074
|
|
12
|
-
python_sendparcel_inpost-0.2.0.dist-info/METADATA,sha256=XKB409WqD0F3SmgsND_xHd43uy29VUeZ5u9_-WBQGQ0,3446
|
|
13
|
-
python_sendparcel_inpost-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
-
python_sendparcel_inpost-0.2.0.dist-info/entry_points.txt,sha256=XuQdmE0LIuIc3TvGPWnYjoB14zXCEof6Q8h1tMp2arE,170
|
|
15
|
-
python_sendparcel_inpost-0.2.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
|
|
16
|
-
python_sendparcel_inpost-0.2.0.dist-info/RECORD,,
|
sendparcel_inpost/enums.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""ShipX-specific enumerations."""
|
|
2
|
-
|
|
3
|
-
from enum import StrEnum
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ShipXService(StrEnum):
|
|
7
|
-
"""ShipX shipment service types."""
|
|
8
|
-
|
|
9
|
-
INPOST_LOCKER_STANDARD = "inpost_locker_standard"
|
|
10
|
-
INPOST_COURIER_STANDARD = "inpost_courier_standard"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ShipXParcelTemplate(StrEnum):
|
|
14
|
-
"""Locker parcel size templates."""
|
|
15
|
-
|
|
16
|
-
SMALL = "small"
|
|
17
|
-
MEDIUM = "medium"
|
|
18
|
-
LARGE = "large"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|