salestax-python 0.1.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.
- salestax/__init__.py +61 -0
- salestax/_config.py +53 -0
- salestax/_transport/__init__.py +18 -0
- salestax/_transport/http_client.py +287 -0
- salestax/_transport/httpx_transport.py +54 -0
- salestax/_transport/retry.py +49 -0
- salestax/_transport/types.py +91 -0
- salestax/_transport/urllib_transport.py +55 -0
- salestax/_transport/user_agent.py +15 -0
- salestax/_version.py +3 -0
- salestax/client.py +215 -0
- salestax/errors.py +202 -0
- salestax/models.py +80 -0
- salestax/py.typed +1 -0
- salestax/resources/__init__.py +5 -0
- salestax/resources/jurisdictions.py +27 -0
- salestax/resources/rates.py +25 -0
- salestax/resources/tax.py +146 -0
- salestax/utils.py +22 -0
- salestax_python-0.1.0.dist-info/METADATA +158 -0
- salestax_python-0.1.0.dist-info/RECORD +23 -0
- salestax_python-0.1.0.dist-info/WHEEL +4 -0
- salestax_python-0.1.0.dist-info/licenses/LICENSE +21 -0
salestax/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Official Python SDK for the Sales Tax Calculator API.
|
|
2
|
+
|
|
3
|
+
Real-time sales tax for 70+ countries, 51 US jurisdictions, and 13 Canadian
|
|
4
|
+
provinces. Batch up to 100 transactions per call.
|
|
5
|
+
|
|
6
|
+
Docs: https://salestaxcalculatorapi.com/docs
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._config import ClientOptions, RetryPolicy
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .client import AsyncSalesTaxClient, SalesTaxClient
|
|
14
|
+
from .errors import (
|
|
15
|
+
ApiError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
ConnectionError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
SalesTaxError,
|
|
23
|
+
ServerError,
|
|
24
|
+
TimeoutError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from .models import (
|
|
28
|
+
BatchResult,
|
|
29
|
+
CalculateTaxParams,
|
|
30
|
+
Jurisdiction,
|
|
31
|
+
JurisdictionQuery,
|
|
32
|
+
TaxBreakdownEntry,
|
|
33
|
+
TaxCalculation,
|
|
34
|
+
TaxRate,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ApiError",
|
|
39
|
+
"AsyncSalesTaxClient",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"BatchResult",
|
|
42
|
+
"CalculateTaxParams",
|
|
43
|
+
"ClientOptions",
|
|
44
|
+
"ConflictError",
|
|
45
|
+
"ConnectionError",
|
|
46
|
+
"Jurisdiction",
|
|
47
|
+
"JurisdictionQuery",
|
|
48
|
+
"NotFoundError",
|
|
49
|
+
"PermissionError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"RetryPolicy",
|
|
52
|
+
"SalesTaxClient",
|
|
53
|
+
"SalesTaxError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"TaxBreakdownEntry",
|
|
56
|
+
"TaxCalculation",
|
|
57
|
+
"TaxRate",
|
|
58
|
+
"TimeoutError",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
salestax/_config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Configuration and defaults for the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.salestaxcalculatorapi.com/v1"
|
|
9
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
10
|
+
MAX_BATCH_SIZE = 100
|
|
11
|
+
ENV_API_KEY = "SALESTAX_API_KEY"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RetryPolicy:
|
|
16
|
+
"""Retry behavior for transient failures.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
max_retries: Number of retries *after* the initial attempt.
|
|
20
|
+
initial_delay_ms: Base delay for the first retry.
|
|
21
|
+
max_delay_ms: Ceiling for any single delay.
|
|
22
|
+
backoff_factor: Exponential multiplier applied per attempt.
|
|
23
|
+
jitter: Apply full jitter to each computed delay.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
max_retries: int = 2
|
|
27
|
+
initial_delay_ms: int = 250
|
|
28
|
+
max_delay_ms: int = 8_000
|
|
29
|
+
backoff_factor: float = 2.0
|
|
30
|
+
jitter: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ClientOptions:
|
|
35
|
+
"""Immutable options container. Prefer passing keyword args to the client."""
|
|
36
|
+
|
|
37
|
+
api_key: str | None = None
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL
|
|
39
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS
|
|
40
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
41
|
+
default_headers: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
chunk_batch: bool = False
|
|
43
|
+
|
|
44
|
+
def resolved_api_key(self) -> str:
|
|
45
|
+
key = self.api_key or os.environ.get(ENV_API_KEY)
|
|
46
|
+
if not key:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Missing API key. Pass api_key=... or set the {ENV_API_KEY} environment variable."
|
|
49
|
+
)
|
|
50
|
+
return key
|
|
51
|
+
|
|
52
|
+
def with_overrides(self, **kwargs: object) -> ClientOptions:
|
|
53
|
+
return replace(self, **kwargs) # type: ignore[arg-type]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .http_client import AsyncHttpClient, HttpClient
|
|
2
|
+
from .retry import compute_delay_ms, parse_retry_after
|
|
3
|
+
from .types import AsyncTransport, Hooks, RequestOptions, SyncTransport
|
|
4
|
+
from .urllib_transport import UrllibTransport
|
|
5
|
+
from .user_agent import build_user_agent
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AsyncHttpClient",
|
|
9
|
+
"AsyncTransport",
|
|
10
|
+
"Hooks",
|
|
11
|
+
"HttpClient",
|
|
12
|
+
"RequestOptions",
|
|
13
|
+
"SyncTransport",
|
|
14
|
+
"UrllibTransport",
|
|
15
|
+
"build_user_agent",
|
|
16
|
+
"compute_delay_ms",
|
|
17
|
+
"parse_retry_after",
|
|
18
|
+
]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""High-level HTTP clients (sync + async).
|
|
2
|
+
|
|
3
|
+
Owns the full request lifecycle:
|
|
4
|
+
|
|
5
|
+
1. Serialize body / build headers (auth, UA, idempotency).
|
|
6
|
+
2. Delegate to the transport.
|
|
7
|
+
3. Retry on retryable failures with backoff + jitter, honoring ``Retry-After``.
|
|
8
|
+
4. Map non-2xx responses to typed errors.
|
|
9
|
+
5. Fire lifecycle hooks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
from .._config import ClientOptions, RetryPolicy
|
|
21
|
+
from ..errors import SalesTaxError, error_from_response
|
|
22
|
+
from .retry import compute_delay_ms, parse_retry_after
|
|
23
|
+
from .types import AsyncTransport, Hooks, RequestOptions, SyncTransport
|
|
24
|
+
from .urllib_transport import UrllibTransport
|
|
25
|
+
from .user_agent import build_user_agent
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
_JSON_DECODE_ERRORS = (json.JSONDecodeError, UnicodeDecodeError, ValueError)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# -- Shared helpers ---------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_headers(
|
|
36
|
+
api_key: str,
|
|
37
|
+
options: ClientOptions,
|
|
38
|
+
request_options: RequestOptions | None,
|
|
39
|
+
) -> dict[str, str]:
|
|
40
|
+
headers: dict[str, str] = {
|
|
41
|
+
"Authorization": f"Bearer {api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
"User-Agent": build_user_agent(),
|
|
45
|
+
}
|
|
46
|
+
headers.update(options.default_headers)
|
|
47
|
+
if request_options and request_options.headers:
|
|
48
|
+
headers.update(request_options.headers)
|
|
49
|
+
if request_options and request_options.idempotency_key:
|
|
50
|
+
headers["Idempotency-Key"] = request_options.idempotency_key
|
|
51
|
+
return headers
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_request_id(headers: Mapping[str, str]) -> str | None:
|
|
55
|
+
for key in ("x-request-id", "request-id", "X-Request-Id"):
|
|
56
|
+
if key in headers:
|
|
57
|
+
return headers[key]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decode_json(raw: bytes) -> Mapping[str, Any]:
|
|
62
|
+
if not raw:
|
|
63
|
+
return {}
|
|
64
|
+
try:
|
|
65
|
+
parsed = json.loads(raw)
|
|
66
|
+
except _JSON_DECODE_ERRORS:
|
|
67
|
+
return {}
|
|
68
|
+
return parsed if isinstance(parsed, Mapping) else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _should_retry(
|
|
72
|
+
err: SalesTaxError,
|
|
73
|
+
attempt: int,
|
|
74
|
+
policy: RetryPolicy,
|
|
75
|
+
request_options: RequestOptions | None,
|
|
76
|
+
) -> bool:
|
|
77
|
+
if request_options and request_options.retryable is False:
|
|
78
|
+
return False
|
|
79
|
+
if not err.retryable:
|
|
80
|
+
return False
|
|
81
|
+
return attempt < policy.max_retries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -- Sync client ------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HttpClient:
|
|
88
|
+
"""Synchronous HTTP client with retry and typed error mapping."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
options: ClientOptions,
|
|
93
|
+
*,
|
|
94
|
+
transport: SyncTransport | None = None,
|
|
95
|
+
hooks: Hooks | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._options = options
|
|
98
|
+
self._api_key = options.resolved_api_key()
|
|
99
|
+
self._base_url = options.base_url.rstrip("/")
|
|
100
|
+
self._timeout_s = options.timeout_ms / 1000.0
|
|
101
|
+
self._retry = options.retry
|
|
102
|
+
self._transport: SyncTransport = transport or UrllibTransport()
|
|
103
|
+
self._hooks = hooks or Hooks()
|
|
104
|
+
|
|
105
|
+
def send(
|
|
106
|
+
self,
|
|
107
|
+
method: str,
|
|
108
|
+
path: str,
|
|
109
|
+
*,
|
|
110
|
+
body: Mapping[str, Any] | None = None,
|
|
111
|
+
options: RequestOptions | None = None,
|
|
112
|
+
) -> Any:
|
|
113
|
+
url = f"{self._base_url}{path}"
|
|
114
|
+
headers = _build_headers(self._api_key, self._options, options)
|
|
115
|
+
encoded = json.dumps(body).encode("utf-8") if body is not None else None
|
|
116
|
+
|
|
117
|
+
attempt = 0
|
|
118
|
+
last_error: SalesTaxError | None = None
|
|
119
|
+
|
|
120
|
+
while attempt <= self._retry.max_retries:
|
|
121
|
+
started = time.monotonic()
|
|
122
|
+
self._hooks.fire("on_request", {"method": method, "url": url, "attempt": attempt})
|
|
123
|
+
try:
|
|
124
|
+
status, resp_headers, raw = self._transport.request(
|
|
125
|
+
method,
|
|
126
|
+
url,
|
|
127
|
+
headers=headers,
|
|
128
|
+
body=encoded,
|
|
129
|
+
timeout_s=self._timeout_s,
|
|
130
|
+
)
|
|
131
|
+
except SalesTaxError as exc:
|
|
132
|
+
last_error = exc
|
|
133
|
+
if not _should_retry(exc, attempt, self._retry, options):
|
|
134
|
+
raise
|
|
135
|
+
delay = compute_delay_ms(attempt, self._retry)
|
|
136
|
+
self._hooks.fire(
|
|
137
|
+
"on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": exc}
|
|
138
|
+
)
|
|
139
|
+
time.sleep(delay / 1000.0)
|
|
140
|
+
attempt += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
request_id = _extract_request_id(resp_headers)
|
|
144
|
+
self._hooks.fire(
|
|
145
|
+
"on_response",
|
|
146
|
+
{
|
|
147
|
+
"status": status,
|
|
148
|
+
"url": url,
|
|
149
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
150
|
+
"request_id": request_id,
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if 200 <= status < 300:
|
|
155
|
+
return _decode_json(raw)
|
|
156
|
+
|
|
157
|
+
parsed = _decode_json(raw)
|
|
158
|
+
err = error_from_response(
|
|
159
|
+
status,
|
|
160
|
+
parsed,
|
|
161
|
+
request_id=request_id,
|
|
162
|
+
retry_after_ms=parse_retry_after(resp_headers.get("retry-after")),
|
|
163
|
+
)
|
|
164
|
+
last_error = err
|
|
165
|
+
|
|
166
|
+
if not _should_retry(err, attempt, self._retry, options):
|
|
167
|
+
raise err
|
|
168
|
+
|
|
169
|
+
retry_after = getattr(err, "retry_after_ms", None)
|
|
170
|
+
delay = compute_delay_ms(attempt, self._retry, retry_after_ms=retry_after)
|
|
171
|
+
self._hooks.fire("on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": err})
|
|
172
|
+
time.sleep(delay / 1000.0)
|
|
173
|
+
attempt += 1
|
|
174
|
+
|
|
175
|
+
assert last_error is not None
|
|
176
|
+
raise last_error
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
"""Release transport resources. No-op for the default transport."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# -- Async client -----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AsyncHttpClient:
|
|
186
|
+
"""Asynchronous HTTP client with retry and typed error mapping."""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
options: ClientOptions,
|
|
191
|
+
*,
|
|
192
|
+
transport: AsyncTransport | None = None,
|
|
193
|
+
hooks: Hooks | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
self._options = options
|
|
196
|
+
self._api_key = options.resolved_api_key()
|
|
197
|
+
self._base_url = options.base_url.rstrip("/")
|
|
198
|
+
self._timeout_s = options.timeout_ms / 1000.0
|
|
199
|
+
self._retry = options.retry
|
|
200
|
+
if transport is None:
|
|
201
|
+
from .httpx_transport import HttpxAsyncTransport
|
|
202
|
+
|
|
203
|
+
transport = HttpxAsyncTransport()
|
|
204
|
+
self._transport: AsyncTransport = transport
|
|
205
|
+
self._hooks = hooks or Hooks()
|
|
206
|
+
|
|
207
|
+
async def send(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
body: Mapping[str, Any] | None = None,
|
|
213
|
+
options: RequestOptions | None = None,
|
|
214
|
+
) -> Any:
|
|
215
|
+
url = f"{self._base_url}{path}"
|
|
216
|
+
headers = _build_headers(self._api_key, self._options, options)
|
|
217
|
+
encoded = json.dumps(body).encode("utf-8") if body is not None else None
|
|
218
|
+
|
|
219
|
+
attempt = 0
|
|
220
|
+
last_error: SalesTaxError | None = None
|
|
221
|
+
|
|
222
|
+
while attempt <= self._retry.max_retries:
|
|
223
|
+
started = time.monotonic()
|
|
224
|
+
self._hooks.fire("on_request", {"method": method, "url": url, "attempt": attempt})
|
|
225
|
+
try:
|
|
226
|
+
status, resp_headers, raw = await self._transport.request(
|
|
227
|
+
method,
|
|
228
|
+
url,
|
|
229
|
+
headers=headers,
|
|
230
|
+
body=encoded,
|
|
231
|
+
timeout_s=self._timeout_s,
|
|
232
|
+
)
|
|
233
|
+
except SalesTaxError as exc:
|
|
234
|
+
last_error = exc
|
|
235
|
+
if not _should_retry(exc, attempt, self._retry, options):
|
|
236
|
+
raise
|
|
237
|
+
delay = compute_delay_ms(attempt, self._retry)
|
|
238
|
+
self._hooks.fire(
|
|
239
|
+
"on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": exc}
|
|
240
|
+
)
|
|
241
|
+
await asyncio.sleep(delay / 1000.0)
|
|
242
|
+
attempt += 1
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
request_id = _extract_request_id(resp_headers)
|
|
246
|
+
self._hooks.fire(
|
|
247
|
+
"on_response",
|
|
248
|
+
{
|
|
249
|
+
"status": status,
|
|
250
|
+
"url": url,
|
|
251
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
252
|
+
"request_id": request_id,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if 200 <= status < 300:
|
|
257
|
+
return _decode_json(raw)
|
|
258
|
+
|
|
259
|
+
parsed = _decode_json(raw)
|
|
260
|
+
err = error_from_response(
|
|
261
|
+
status,
|
|
262
|
+
parsed,
|
|
263
|
+
request_id=request_id,
|
|
264
|
+
retry_after_ms=parse_retry_after(resp_headers.get("retry-after")),
|
|
265
|
+
)
|
|
266
|
+
last_error = err
|
|
267
|
+
|
|
268
|
+
if not _should_retry(err, attempt, self._retry, options):
|
|
269
|
+
raise err
|
|
270
|
+
|
|
271
|
+
retry_after = getattr(err, "retry_after_ms", None)
|
|
272
|
+
delay = compute_delay_ms(attempt, self._retry, retry_after_ms=retry_after)
|
|
273
|
+
self._hooks.fire("on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": err})
|
|
274
|
+
await asyncio.sleep(delay / 1000.0)
|
|
275
|
+
attempt += 1
|
|
276
|
+
|
|
277
|
+
assert last_error is not None
|
|
278
|
+
raise last_error
|
|
279
|
+
|
|
280
|
+
async def aclose(self) -> None:
|
|
281
|
+
await self._transport.aclose()
|
|
282
|
+
|
|
283
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
287
|
+
await self.aclose()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Optional async transport backed by httpx.
|
|
2
|
+
|
|
3
|
+
Only imported when the user calls :class:`AsyncSalesTaxClient` or passes an
|
|
4
|
+
instance explicitly. Installing the ``[async]`` extra is required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from ..errors import ConnectionError, TimeoutError
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import httpx
|
|
15
|
+
except ImportError as exc: # pragma: no cover - import-guard
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"The async client requires httpx. Install with: pip install salestax-python[async]"
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpxAsyncTransport:
|
|
22
|
+
"""httpx.AsyncClient-backed transport. One connection pool per instance."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._client = httpx.AsyncClient()
|
|
26
|
+
|
|
27
|
+
async def request(
|
|
28
|
+
self,
|
|
29
|
+
method: str,
|
|
30
|
+
url: str,
|
|
31
|
+
*,
|
|
32
|
+
headers: Mapping[str, str],
|
|
33
|
+
body: bytes | None,
|
|
34
|
+
timeout_s: float,
|
|
35
|
+
) -> tuple[int, Mapping[str, str], bytes]:
|
|
36
|
+
try:
|
|
37
|
+
resp = await self._client.request(
|
|
38
|
+
method, url, headers=dict(headers), content=body, timeout=timeout_s
|
|
39
|
+
)
|
|
40
|
+
except httpx.TimeoutException as exc:
|
|
41
|
+
raise TimeoutError(int(timeout_s * 1000)) from exc
|
|
42
|
+
except httpx.TransportError as exc:
|
|
43
|
+
raise ConnectionError(str(exc)) from exc
|
|
44
|
+
|
|
45
|
+
return resp.status_code, dict(resp.headers), resp.content
|
|
46
|
+
|
|
47
|
+
async def aclose(self) -> None:
|
|
48
|
+
await self._client.aclose()
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> HttpxAsyncTransport:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
54
|
+
await self.aclose()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Backoff computation and ``Retry-After`` parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
|
|
9
|
+
from .._config import RetryPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_delay_ms(
|
|
13
|
+
attempt: int,
|
|
14
|
+
policy: RetryPolicy,
|
|
15
|
+
*,
|
|
16
|
+
retry_after_ms: int | None = None,
|
|
17
|
+
) -> int:
|
|
18
|
+
"""Return the delay in milliseconds before retry ``attempt`` (0-indexed)."""
|
|
19
|
+
if retry_after_ms is not None:
|
|
20
|
+
return max(0, retry_after_ms)
|
|
21
|
+
|
|
22
|
+
exponential = policy.initial_delay_ms * (policy.backoff_factor**attempt)
|
|
23
|
+
capped = min(exponential, policy.max_delay_ms)
|
|
24
|
+
if policy.jitter:
|
|
25
|
+
return int(random.random() * capped)
|
|
26
|
+
return int(capped)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_retry_after(value: str | None) -> int | None:
|
|
30
|
+
"""Parse a ``Retry-After`` header into milliseconds.
|
|
31
|
+
|
|
32
|
+
Supports both the delay-seconds and HTTP-date forms per RFC 7231.
|
|
33
|
+
Returns ``None`` when the header is absent or unparseable.
|
|
34
|
+
"""
|
|
35
|
+
if not value:
|
|
36
|
+
return None
|
|
37
|
+
value = value.strip()
|
|
38
|
+
if value.isdigit():
|
|
39
|
+
return int(value) * 1000
|
|
40
|
+
try:
|
|
41
|
+
when = parsedate_to_datetime(value)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
if when is None:
|
|
45
|
+
return None
|
|
46
|
+
if when.tzinfo is None:
|
|
47
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
48
|
+
delta_ms = int((when - datetime.now(timezone.utc)).total_seconds() * 1000)
|
|
49
|
+
return max(0, delta_ms)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Transport-layer contracts: request options, hooks, transport protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from ..errors import SalesTaxError
|
|
11
|
+
|
|
12
|
+
# -- Request options --------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RequestOptions:
|
|
17
|
+
"""Per-request overrides."""
|
|
18
|
+
|
|
19
|
+
idempotency_key: str | None = None
|
|
20
|
+
headers: dict[str, str] | None = None
|
|
21
|
+
retryable: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- Hooks ------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Hooks:
|
|
29
|
+
"""Lifecycle callbacks. All are optional and called synchronously.
|
|
30
|
+
|
|
31
|
+
Exceptions raised inside hooks are swallowed so a buggy hook cannot
|
|
32
|
+
break request flow.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
on_request: Callable[[dict[str, Any]], None] | None = None
|
|
36
|
+
on_response: Callable[[dict[str, Any]], None] | None = None
|
|
37
|
+
on_retry: Callable[[dict[str, Any]], None] | None = None
|
|
38
|
+
|
|
39
|
+
def fire(self, name: str, info: Mapping[str, Any]) -> None:
|
|
40
|
+
cb = getattr(self, name, None)
|
|
41
|
+
if cb is None:
|
|
42
|
+
return
|
|
43
|
+
with contextlib.suppress(Exception):
|
|
44
|
+
cb(dict(info))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- Transport protocol -----------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SyncTransport(Protocol):
|
|
51
|
+
"""Minimal synchronous transport contract.
|
|
52
|
+
|
|
53
|
+
Implementations take a fully-prepared request and return a tuple of
|
|
54
|
+
``(status_code, response_headers, raw_body_bytes)``. They must not
|
|
55
|
+
raise for non-2xx responses — only for genuine network failures.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def request(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
url: str,
|
|
62
|
+
*,
|
|
63
|
+
headers: Mapping[str, str],
|
|
64
|
+
body: bytes | None,
|
|
65
|
+
timeout_s: float,
|
|
66
|
+
) -> tuple[int, Mapping[str, str], bytes]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AsyncTransport(Protocol):
|
|
70
|
+
"""Minimal asynchronous transport contract. Mirror of :class:`SyncTransport`."""
|
|
71
|
+
|
|
72
|
+
async def request(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
url: str,
|
|
76
|
+
*,
|
|
77
|
+
headers: Mapping[str, str],
|
|
78
|
+
body: bytes | None,
|
|
79
|
+
timeout_s: float,
|
|
80
|
+
) -> tuple[int, Mapping[str, str], bytes]: ...
|
|
81
|
+
|
|
82
|
+
async def aclose(self) -> None: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"AsyncTransport",
|
|
87
|
+
"Hooks",
|
|
88
|
+
"RequestOptions",
|
|
89
|
+
"SalesTaxError",
|
|
90
|
+
"SyncTransport",
|
|
91
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Default synchronous transport — stdlib only, zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
from ..errors import ConnectionError, TimeoutError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UrllibTransport:
|
|
16
|
+
"""Stdlib-backed synchronous HTTP transport.
|
|
17
|
+
|
|
18
|
+
Only raises :class:`ConnectionError` and :class:`TimeoutError`.
|
|
19
|
+
HTTP status codes are returned to the caller as data, never raised.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def request(
|
|
23
|
+
self,
|
|
24
|
+
method: str,
|
|
25
|
+
url: str,
|
|
26
|
+
*,
|
|
27
|
+
headers: Mapping[str, str],
|
|
28
|
+
body: bytes | None,
|
|
29
|
+
timeout_s: float,
|
|
30
|
+
) -> tuple[int, Mapping[str, str], bytes]:
|
|
31
|
+
req = urllib.request.Request(url, data=body, method=method)
|
|
32
|
+
for key, value in headers.items():
|
|
33
|
+
req.add_header(key, value)
|
|
34
|
+
|
|
35
|
+
# Order matters:
|
|
36
|
+
# HTTPError is a subclass of URLError and OSError
|
|
37
|
+
# URLError is a subclass of OSError
|
|
38
|
+
# TimeoutError/socket.timeout is a subclass of OSError
|
|
39
|
+
# Catch the most specific first.
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
42
|
+
return resp.status, dict(resp.headers.items()), resp.read()
|
|
43
|
+
except urllib.error.HTTPError as exc:
|
|
44
|
+
# HTTP errors still carry a response body we want to surface.
|
|
45
|
+
raw = exc.read()
|
|
46
|
+
return exc.code, dict(exc.headers.items()) if exc.headers else {}, raw
|
|
47
|
+
except urllib.error.URLError as exc:
|
|
48
|
+
reason = getattr(exc, "reason", exc)
|
|
49
|
+
if isinstance(reason, (socket.timeout, builtins.TimeoutError)):
|
|
50
|
+
raise TimeoutError(int(timeout_s * 1000)) from exc
|
|
51
|
+
raise ConnectionError(str(reason)) from exc
|
|
52
|
+
except (socket.timeout, builtins.TimeoutError) as exc:
|
|
53
|
+
raise TimeoutError(int(timeout_s * 1000)) from exc
|
|
54
|
+
except (ssl.SSLError, OSError) as exc:
|
|
55
|
+
raise ConnectionError(str(exc)) from exc
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""User-Agent construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .._version import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_user_agent() -> str:
|
|
12
|
+
"""Return a stable, informative User-Agent string."""
|
|
13
|
+
py = f"python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
14
|
+
os_info = f"{platform.system().lower()}/{platform.release()}"
|
|
15
|
+
return f"salestax-python/{__version__} ({py}; {os_info})"
|
salestax/_version.py
ADDED