threecommon 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.
- threecommon/__init__.py +79 -0
- threecommon/_core/__init__.py +6 -0
- threecommon/_core/headers.py +41 -0
- threecommon/_core/http_client.py +424 -0
- threecommon/_core/parse.py +77 -0
- threecommon/_core/retry.py +80 -0
- threecommon/_core/telemetry.py +77 -0
- threecommon/_core/url.py +31 -0
- threecommon/_generated/__init__.py +8 -0
- threecommon/_generated/models.py +614 -0
- threecommon/api_version.py +15 -0
- threecommon/client.py +184 -0
- threecommon/config.py +140 -0
- threecommon/errors/__init__.py +35 -0
- threecommon/errors/base.py +81 -0
- threecommon/errors/classes.py +75 -0
- threecommon/events/__init__.py +28 -0
- threecommon/events/service.py +170 -0
- threecommon/events/types.py +124 -0
- threecommon/filters/__init__.py +51 -0
- threecommon/filters/builder.py +188 -0
- threecommon/filters/types.py +69 -0
- threecommon/helpers.py +19 -0
- threecommon/invoices/__init__.py +40 -0
- threecommon/invoices/service.py +266 -0
- threecommon/invoices/types.py +195 -0
- threecommon/pagination/__init__.py +12 -0
- threecommon/pagination/auto_paginator.py +98 -0
- threecommon/py.typed +0 -0
- threecommon/version.py +10 -0
- threecommon-0.1.0.dist-info/METADATA +399 -0
- threecommon-0.1.0.dist-info/RECORD +34 -0
- threecommon-0.1.0.dist-info/WHEEL +4 -0
- threecommon-0.1.0.dist-info/licenses/LICENSE +21 -0
threecommon/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Official Python client for the 3Common Public API.
|
|
2
|
+
|
|
3
|
+
Top-level entry points:
|
|
4
|
+
|
|
5
|
+
* [ThreeCommon][threecommon.ThreeCommon] — synchronous client
|
|
6
|
+
* [AsyncThreeCommon][threecommon.AsyncThreeCommon] — async client (httpx-backed)
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
|
|
10
|
+
from threecommon import ThreeCommon
|
|
11
|
+
|
|
12
|
+
client = ThreeCommon(api_key="3co_...")
|
|
13
|
+
result = client.events.list(status="open", page_size=50)
|
|
14
|
+
|
|
15
|
+
for event in client.events.list_auto_paginate(status="open"):
|
|
16
|
+
print(event.name)
|
|
17
|
+
|
|
18
|
+
Async equivalent:
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
from threecommon import AsyncThreeCommon
|
|
22
|
+
|
|
23
|
+
async def main() -> None:
|
|
24
|
+
async with AsyncThreeCommon(api_key="3co_...") as client:
|
|
25
|
+
async for event in client.events.list_auto_paginate(status="open"):
|
|
26
|
+
print(event.name)
|
|
27
|
+
|
|
28
|
+
asyncio.run(main())
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from threecommon.api_version import API_PATH, API_VERSION
|
|
32
|
+
from threecommon.client import AsyncThreeCommon, ThreeCommon
|
|
33
|
+
from threecommon.config import (
|
|
34
|
+
DEFAULT_BASE_URL,
|
|
35
|
+
DEFAULT_MAX_RETRIES,
|
|
36
|
+
DEFAULT_RETRY_DELAY,
|
|
37
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
38
|
+
ClientConfig,
|
|
39
|
+
RetryDelay,
|
|
40
|
+
)
|
|
41
|
+
from threecommon.errors.base import APIError
|
|
42
|
+
from threecommon.errors.classes import (
|
|
43
|
+
AuthError,
|
|
44
|
+
ConflictError,
|
|
45
|
+
ConnectionError,
|
|
46
|
+
NotFoundError,
|
|
47
|
+
PermissionError,
|
|
48
|
+
RateLimitError,
|
|
49
|
+
ServerError,
|
|
50
|
+
ValidationError,
|
|
51
|
+
)
|
|
52
|
+
from threecommon.version import VERSION, __version__
|
|
53
|
+
|
|
54
|
+
__all__ = (
|
|
55
|
+
# Constants
|
|
56
|
+
"API_PATH",
|
|
57
|
+
"API_VERSION",
|
|
58
|
+
"DEFAULT_BASE_URL",
|
|
59
|
+
"DEFAULT_MAX_RETRIES",
|
|
60
|
+
"DEFAULT_RETRY_DELAY",
|
|
61
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
62
|
+
"VERSION",
|
|
63
|
+
# Errors
|
|
64
|
+
"APIError",
|
|
65
|
+
"AsyncThreeCommon",
|
|
66
|
+
"AuthError",
|
|
67
|
+
"ClientConfig",
|
|
68
|
+
"ConflictError",
|
|
69
|
+
"ConnectionError",
|
|
70
|
+
"NotFoundError",
|
|
71
|
+
"PermissionError",
|
|
72
|
+
"RateLimitError",
|
|
73
|
+
"RetryDelay",
|
|
74
|
+
"ServerError",
|
|
75
|
+
# Clients
|
|
76
|
+
"ThreeCommon",
|
|
77
|
+
"ValidationError",
|
|
78
|
+
"__version__",
|
|
79
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Internal HTTP transport. Not part of the public API.
|
|
2
|
+
|
|
3
|
+
Decomposed into one concern per file: URL building, header construction,
|
|
4
|
+
retry policy, response parsing, telemetry header. The sync/async clients in
|
|
5
|
+
[http_client][threecommon._core.http_client] orchestrate them.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Header builder. Pure function over already-resolved values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def user_agent_suffix(extra: str | None = None) -> str:
|
|
10
|
+
"""Runtime + OS portion of the ``User-Agent`` header."""
|
|
11
|
+
parts = [
|
|
12
|
+
f"Python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
13
|
+
f"{platform.system()}-{platform.machine()}",
|
|
14
|
+
]
|
|
15
|
+
if extra:
|
|
16
|
+
parts.append(extra)
|
|
17
|
+
return "; ".join(parts)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_headers(
|
|
21
|
+
*,
|
|
22
|
+
api_key: str,
|
|
23
|
+
api_version: str,
|
|
24
|
+
sdk_version: str,
|
|
25
|
+
user_agent_extra: str | None = None,
|
|
26
|
+
telemetry_header: str | None = None,
|
|
27
|
+
idempotency_key: str | None = None,
|
|
28
|
+
) -> dict[str, str]:
|
|
29
|
+
"""Return a fresh header dict populated with every header the SDK sends."""
|
|
30
|
+
headers: dict[str, str] = {
|
|
31
|
+
"Authorization": f"Bearer {api_key}",
|
|
32
|
+
"Threecommon-Version": api_version,
|
|
33
|
+
"User-Agent": f"ThreeCommonPython/{sdk_version} ({user_agent_suffix(user_agent_extra)})",
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
if telemetry_header:
|
|
38
|
+
headers["Threecommon-Client-Telemetry"] = telemetry_header
|
|
39
|
+
if idempotency_key:
|
|
40
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
41
|
+
return headers
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Sync + async request orchestrators.
|
|
2
|
+
|
|
3
|
+
Both classes wrap an [httpx][https://www.python-httpx.org/] client and
|
|
4
|
+
compose the pure modules in this folder into a complete request lifecycle:
|
|
5
|
+
build URL → build headers → send → parse → map errors → retry. The
|
|
6
|
+
sync/async split is at the I/O boundary only; everything else is shared.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from threecommon._core.headers import build_headers
|
|
21
|
+
from threecommon._core.parse import (
|
|
22
|
+
parse_error_body,
|
|
23
|
+
parse_retry_after,
|
|
24
|
+
parse_success_body,
|
|
25
|
+
request_id_of,
|
|
26
|
+
)
|
|
27
|
+
from threecommon._core.retry import (
|
|
28
|
+
RetryPolicy,
|
|
29
|
+
compute_backoff,
|
|
30
|
+
is_idempotent,
|
|
31
|
+
is_retryable_status,
|
|
32
|
+
)
|
|
33
|
+
from threecommon._core.telemetry import Telemetry
|
|
34
|
+
from threecommon._core.url import build_url
|
|
35
|
+
from threecommon.api_version import API_PATH
|
|
36
|
+
from threecommon.errors.base import APIError
|
|
37
|
+
from threecommon.errors.classes import (
|
|
38
|
+
AuthError,
|
|
39
|
+
ConflictError,
|
|
40
|
+
ConnectionError,
|
|
41
|
+
NotFoundError,
|
|
42
|
+
PermissionError,
|
|
43
|
+
RateLimitError,
|
|
44
|
+
ServerError,
|
|
45
|
+
ValidationError,
|
|
46
|
+
)
|
|
47
|
+
from threecommon.version import VERSION
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class Request:
|
|
52
|
+
"""One logical SDK call. The HTTP clients fill in the rest."""
|
|
53
|
+
|
|
54
|
+
method: str
|
|
55
|
+
path: str
|
|
56
|
+
query: dict[str, str] | None = None
|
|
57
|
+
body: dict[str, Any] | None = None
|
|
58
|
+
idempotency_key: str | None = None
|
|
59
|
+
timeout_seconds: float | None = None
|
|
60
|
+
max_retries: int | None = None # negative → disable retries for this call
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class _Resolved:
|
|
65
|
+
"""Pre-resolved per-call values shared by sync + async paths."""
|
|
66
|
+
|
|
67
|
+
url: str
|
|
68
|
+
method: str
|
|
69
|
+
body: dict[str, Any] | None
|
|
70
|
+
idempotency_key: str | None
|
|
71
|
+
max_retries: int
|
|
72
|
+
timeout_seconds: float
|
|
73
|
+
is_idempotent: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
# Common (pure) helpers
|
|
78
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve(
|
|
82
|
+
req: Request,
|
|
83
|
+
*,
|
|
84
|
+
base_url: str,
|
|
85
|
+
api_version_header: str,
|
|
86
|
+
default_timeout: float,
|
|
87
|
+
default_max_retries: int,
|
|
88
|
+
) -> _Resolved:
|
|
89
|
+
_ = api_version_header
|
|
90
|
+
max_retries = default_max_retries
|
|
91
|
+
if req.max_retries is not None:
|
|
92
|
+
max_retries = 0 if req.max_retries < 0 else req.max_retries
|
|
93
|
+
return _Resolved(
|
|
94
|
+
url=build_url(base_url=base_url, api_path=API_PATH, path=req.path, query=req.query),
|
|
95
|
+
method=req.method.upper(),
|
|
96
|
+
body=req.body,
|
|
97
|
+
idempotency_key=req.idempotency_key,
|
|
98
|
+
max_retries=max_retries,
|
|
99
|
+
timeout_seconds=req.timeout_seconds if req.timeout_seconds is not None else default_timeout,
|
|
100
|
+
is_idempotent=is_idempotent(
|
|
101
|
+
req.method.upper(), has_idempotency_key=req.idempotency_key is not None
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Status-code -> typed-exception mapping. ValidationError is the catch-all
|
|
107
|
+
# for any unmapped 4xx; ServerError covers >= 500.
|
|
108
|
+
_STATUS_TO_ERROR: dict[int, type[APIError]] = {
|
|
109
|
+
HTTPStatus.UNAUTHORIZED: AuthError,
|
|
110
|
+
HTTPStatus.FORBIDDEN: PermissionError,
|
|
111
|
+
HTTPStatus.NOT_FOUND: NotFoundError,
|
|
112
|
+
HTTPStatus.CONFLICT: ConflictError,
|
|
113
|
+
HTTPStatus.BAD_REQUEST: ValidationError,
|
|
114
|
+
HTTPStatus.UNPROCESSABLE_ENTITY: ValidationError,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Status-code -> SDK error.code default. Used when the API didn't return a
|
|
118
|
+
# parsable error envelope.
|
|
119
|
+
_STATUS_TO_CODE: dict[int, str] = {
|
|
120
|
+
HTTPStatus.UNAUTHORIZED: "unauthorized",
|
|
121
|
+
HTTPStatus.FORBIDDEN: "forbidden",
|
|
122
|
+
HTTPStatus.NOT_FOUND: "not_found",
|
|
123
|
+
HTTPStatus.CONFLICT: "conflict",
|
|
124
|
+
HTTPStatus.TOO_MANY_REQUESTS: "rate_limit_exceeded",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _map_error_response(response: httpx.Response, retry_after: float | None) -> APIError:
|
|
129
|
+
"""Turn a non-2xx response into the typed exception subclass."""
|
|
130
|
+
code, message, details = parse_error_body(response.text)
|
|
131
|
+
if not code:
|
|
132
|
+
code = _default_code_for_status(response.status_code)
|
|
133
|
+
if not message:
|
|
134
|
+
message = f"Request failed with status {response.status_code}"
|
|
135
|
+
|
|
136
|
+
base_kwargs: dict[str, Any] = {
|
|
137
|
+
"code": code,
|
|
138
|
+
"message": message,
|
|
139
|
+
"http_status": response.status_code,
|
|
140
|
+
"request_id": request_id_of(response),
|
|
141
|
+
"details": details,
|
|
142
|
+
"raw_response": response.text or None,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
status = response.status_code
|
|
146
|
+
if status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
147
|
+
return RateLimitError(**base_kwargs, retry_after_seconds=retry_after)
|
|
148
|
+
if status in _STATUS_TO_ERROR:
|
|
149
|
+
return _STATUS_TO_ERROR[status](**base_kwargs)
|
|
150
|
+
if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
151
|
+
return ServerError(**base_kwargs)
|
|
152
|
+
return ValidationError(**base_kwargs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _wrap_connection(message: str, cause: BaseException) -> ConnectionError:
|
|
156
|
+
return ConnectionError(code="connection_error", message=message, cause=cause)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _default_code_for_status(status: int) -> str:
|
|
160
|
+
code = _STATUS_TO_CODE.get(status)
|
|
161
|
+
if code is not None:
|
|
162
|
+
return code
|
|
163
|
+
if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
164
|
+
return "internal_error"
|
|
165
|
+
return "request_failed"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_request_headers(
|
|
169
|
+
*,
|
|
170
|
+
api_key: str,
|
|
171
|
+
api_version: str,
|
|
172
|
+
telemetry: Telemetry,
|
|
173
|
+
idempotency_key: str | None,
|
|
174
|
+
user_agent_extra: str | None,
|
|
175
|
+
) -> dict[str, str]:
|
|
176
|
+
return build_headers(
|
|
177
|
+
api_key=api_key,
|
|
178
|
+
api_version=api_version,
|
|
179
|
+
sdk_version=VERSION,
|
|
180
|
+
user_agent_extra=user_agent_extra,
|
|
181
|
+
telemetry_header=telemetry.header_value(sdk_version=VERSION, api_version=api_version),
|
|
182
|
+
idempotency_key=idempotency_key,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
# Sync
|
|
188
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(slots=True)
|
|
192
|
+
class HTTPClientOptions:
|
|
193
|
+
"""Configuration accepted by :class:`HTTPClient` / :class:`AsyncHTTPClient`."""
|
|
194
|
+
|
|
195
|
+
api_key: str
|
|
196
|
+
base_url: str
|
|
197
|
+
api_version: str
|
|
198
|
+
timeout_seconds: float
|
|
199
|
+
retry: RetryPolicy
|
|
200
|
+
telemetry: Telemetry
|
|
201
|
+
logger: logging.Logger | None = None
|
|
202
|
+
user_agent_extra: str | None = None
|
|
203
|
+
httpx_client: httpx.Client | None = field(default=None, repr=False)
|
|
204
|
+
async_httpx_client: httpx.AsyncClient | None = field(default=None, repr=False)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class HTTPClient:
|
|
208
|
+
"""Sync request orchestrator. One instance per [ThreeCommon] client."""
|
|
209
|
+
|
|
210
|
+
__slots__ = ("_opts", "_owns_httpx", "httpx")
|
|
211
|
+
|
|
212
|
+
def __init__(self, opts: HTTPClientOptions) -> None:
|
|
213
|
+
self._opts = opts
|
|
214
|
+
if opts.httpx_client is not None:
|
|
215
|
+
self.httpx = opts.httpx_client
|
|
216
|
+
self._owns_httpx = False
|
|
217
|
+
else:
|
|
218
|
+
self.httpx = httpx.Client(timeout=opts.timeout_seconds)
|
|
219
|
+
self._owns_httpx = True
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
"""Close the underlying httpx client if we created it."""
|
|
223
|
+
if self._owns_httpx:
|
|
224
|
+
self.httpx.close()
|
|
225
|
+
|
|
226
|
+
def request(self, req: Request) -> Any:
|
|
227
|
+
"""Send a request honoring the client's retry policy.
|
|
228
|
+
|
|
229
|
+
Returns the decoded JSON body for 2xx responses, or raises a
|
|
230
|
+
[APIError][threecommon.APIError] subclass.
|
|
231
|
+
"""
|
|
232
|
+
resolved = _resolve(
|
|
233
|
+
req,
|
|
234
|
+
base_url=self._opts.base_url,
|
|
235
|
+
api_version_header=self._opts.api_version,
|
|
236
|
+
default_timeout=self._opts.timeout_seconds,
|
|
237
|
+
default_max_retries=self._opts.retry.max_retries,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
attempt = 0
|
|
241
|
+
while True:
|
|
242
|
+
headers = _build_request_headers(
|
|
243
|
+
api_key=self._opts.api_key,
|
|
244
|
+
api_version=self._opts.api_version,
|
|
245
|
+
telemetry=self._opts.telemetry,
|
|
246
|
+
idempotency_key=resolved.idempotency_key,
|
|
247
|
+
user_agent_extra=self._opts.user_agent_extra,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
start = time.monotonic()
|
|
251
|
+
try:
|
|
252
|
+
response = self.httpx.request(
|
|
253
|
+
method=resolved.method,
|
|
254
|
+
url=resolved.url,
|
|
255
|
+
headers=headers,
|
|
256
|
+
json=resolved.body,
|
|
257
|
+
timeout=resolved.timeout_seconds,
|
|
258
|
+
)
|
|
259
|
+
except (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError) as exc:
|
|
260
|
+
if resolved.is_idempotent and attempt < resolved.max_retries:
|
|
261
|
+
time.sleep(
|
|
262
|
+
compute_backoff(
|
|
263
|
+
attempt=attempt, retry_after_seconds=None, policy=self._opts.retry
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
attempt += 1
|
|
267
|
+
continue
|
|
268
|
+
raise _wrap_connection(str(exc) or "network error", exc) from exc
|
|
269
|
+
|
|
270
|
+
duration = time.monotonic() - start
|
|
271
|
+
self._opts.telemetry.record(
|
|
272
|
+
method=resolved.method,
|
|
273
|
+
path=req.path,
|
|
274
|
+
status=response.status_code,
|
|
275
|
+
duration_seconds=duration,
|
|
276
|
+
)
|
|
277
|
+
if self._opts.logger is not None:
|
|
278
|
+
self._opts.logger.debug(
|
|
279
|
+
"threecommon:request",
|
|
280
|
+
extra={
|
|
281
|
+
"method": resolved.method,
|
|
282
|
+
"path": req.path,
|
|
283
|
+
"status": response.status_code,
|
|
284
|
+
"duration_ms": int(duration * 1000),
|
|
285
|
+
"request_id": request_id_of(response),
|
|
286
|
+
"attempt": attempt,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if response.is_success:
|
|
291
|
+
return parse_success_body(response.text)
|
|
292
|
+
|
|
293
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
294
|
+
if (
|
|
295
|
+
resolved.is_idempotent
|
|
296
|
+
and attempt < resolved.max_retries
|
|
297
|
+
and is_retryable_status(response.status_code)
|
|
298
|
+
):
|
|
299
|
+
time.sleep(
|
|
300
|
+
compute_backoff(
|
|
301
|
+
attempt=attempt, retry_after_seconds=retry_after, policy=self._opts.retry
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
attempt += 1
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
raise _map_error_response(response, retry_after)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
# Async
|
|
312
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class AsyncHTTPClient:
|
|
316
|
+
"""Async request orchestrator. One instance per [AsyncThreeCommon] client."""
|
|
317
|
+
|
|
318
|
+
__slots__ = ("_opts", "_owns_httpx", "httpx")
|
|
319
|
+
|
|
320
|
+
def __init__(self, opts: HTTPClientOptions) -> None:
|
|
321
|
+
self._opts = opts
|
|
322
|
+
if opts.async_httpx_client is not None:
|
|
323
|
+
self.httpx = opts.async_httpx_client
|
|
324
|
+
self._owns_httpx = False
|
|
325
|
+
else:
|
|
326
|
+
self.httpx = httpx.AsyncClient(timeout=opts.timeout_seconds)
|
|
327
|
+
self._owns_httpx = True
|
|
328
|
+
|
|
329
|
+
async def aclose(self) -> None:
|
|
330
|
+
"""Close the underlying httpx client if we created it."""
|
|
331
|
+
if self._owns_httpx:
|
|
332
|
+
await self.httpx.aclose()
|
|
333
|
+
|
|
334
|
+
async def request(self, req: Request) -> Any:
|
|
335
|
+
"""Send a request honoring the client's retry policy.
|
|
336
|
+
|
|
337
|
+
Async variant of [HTTPClient.request].
|
|
338
|
+
"""
|
|
339
|
+
resolved = _resolve(
|
|
340
|
+
req,
|
|
341
|
+
base_url=self._opts.base_url,
|
|
342
|
+
api_version_header=self._opts.api_version,
|
|
343
|
+
default_timeout=self._opts.timeout_seconds,
|
|
344
|
+
default_max_retries=self._opts.retry.max_retries,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
attempt = 0
|
|
348
|
+
while True:
|
|
349
|
+
headers = _build_request_headers(
|
|
350
|
+
api_key=self._opts.api_key,
|
|
351
|
+
api_version=self._opts.api_version,
|
|
352
|
+
telemetry=self._opts.telemetry,
|
|
353
|
+
idempotency_key=resolved.idempotency_key,
|
|
354
|
+
user_agent_extra=self._opts.user_agent_extra,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
start = time.monotonic()
|
|
358
|
+
try:
|
|
359
|
+
response = await self.httpx.request(
|
|
360
|
+
method=resolved.method,
|
|
361
|
+
url=resolved.url,
|
|
362
|
+
headers=headers,
|
|
363
|
+
json=resolved.body,
|
|
364
|
+
timeout=resolved.timeout_seconds,
|
|
365
|
+
)
|
|
366
|
+
except (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError) as exc:
|
|
367
|
+
if resolved.is_idempotent and attempt < resolved.max_retries:
|
|
368
|
+
await asyncio.sleep(
|
|
369
|
+
compute_backoff(
|
|
370
|
+
attempt=attempt, retry_after_seconds=None, policy=self._opts.retry
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
attempt += 1
|
|
374
|
+
continue
|
|
375
|
+
raise _wrap_connection(str(exc) or "network error", exc) from exc
|
|
376
|
+
|
|
377
|
+
duration = time.monotonic() - start
|
|
378
|
+
self._opts.telemetry.record(
|
|
379
|
+
method=resolved.method,
|
|
380
|
+
path=req.path,
|
|
381
|
+
status=response.status_code,
|
|
382
|
+
duration_seconds=duration,
|
|
383
|
+
)
|
|
384
|
+
if self._opts.logger is not None:
|
|
385
|
+
self._opts.logger.debug(
|
|
386
|
+
"threecommon:request",
|
|
387
|
+
extra={
|
|
388
|
+
"method": resolved.method,
|
|
389
|
+
"path": req.path,
|
|
390
|
+
"status": response.status_code,
|
|
391
|
+
"duration_ms": int(duration * 1000),
|
|
392
|
+
"request_id": request_id_of(response),
|
|
393
|
+
"attempt": attempt,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if response.is_success:
|
|
398
|
+
return parse_success_body(response.text)
|
|
399
|
+
|
|
400
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
401
|
+
if (
|
|
402
|
+
resolved.is_idempotent
|
|
403
|
+
and attempt < resolved.max_retries
|
|
404
|
+
and is_retryable_status(response.status_code)
|
|
405
|
+
):
|
|
406
|
+
await asyncio.sleep(
|
|
407
|
+
compute_backoff(
|
|
408
|
+
attempt=attempt,
|
|
409
|
+
retry_after_seconds=retry_after,
|
|
410
|
+
policy=self._opts.retry,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
attempt += 1
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
raise _map_error_response(response, retry_after)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
__all__ = (
|
|
420
|
+
"AsyncHTTPClient",
|
|
421
|
+
"HTTPClient",
|
|
422
|
+
"HTTPClientOptions",
|
|
423
|
+
"Request",
|
|
424
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Response parsing helpers. Pure functions over text bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_success_body(body_text: str) -> Any:
|
|
14
|
+
"""Decode a 2xx body. Empty or non-JSON resolves to ``None``."""
|
|
15
|
+
if not body_text:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(body_text)
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_error_body(body_text: str) -> tuple[str, str, dict[str, Any] | None]:
|
|
24
|
+
"""Trying to parse the API's standard ``{"error": {...}}`` envelope.
|
|
25
|
+
|
|
26
|
+
Returns ``("", "", None)`` when the body is missing or malformed; callers
|
|
27
|
+
fall back to status-based defaults.
|
|
28
|
+
"""
|
|
29
|
+
if not body_text:
|
|
30
|
+
return ("", "", None)
|
|
31
|
+
try:
|
|
32
|
+
envelope = json.loads(body_text)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
return ("", "", None)
|
|
35
|
+
if not isinstance(envelope, dict):
|
|
36
|
+
return ("", "", None)
|
|
37
|
+
err = envelope.get("error")
|
|
38
|
+
if not isinstance(err, dict):
|
|
39
|
+
return ("", "", None)
|
|
40
|
+
code = err.get("code", "") if isinstance(err.get("code"), str) else ""
|
|
41
|
+
message = err.get("message", "") if isinstance(err.get("message"), str) else ""
|
|
42
|
+
details = err.get("details") if isinstance(err.get("details"), dict) else None
|
|
43
|
+
return (code, message, details)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_retry_after(header: str | None) -> float | None:
|
|
47
|
+
"""Parse a ``Retry-After`` header into seconds.
|
|
48
|
+
|
|
49
|
+
Accepts either a delta-seconds value or an HTTP-date. Returns ``None``
|
|
50
|
+
when the header is missing or malformed; ``0`` when the date is in the
|
|
51
|
+
past.
|
|
52
|
+
"""
|
|
53
|
+
if not header:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
seconds = float(header)
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
else:
|
|
60
|
+
return max(seconds, 0.0)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
target = parsedate_to_datetime(header)
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
now = datetime.now(tz=timezone.utc)
|
|
68
|
+
if target.tzinfo is None:
|
|
69
|
+
target = target.replace(tzinfo=timezone.utc)
|
|
70
|
+
delta = (target - now).total_seconds()
|
|
71
|
+
return max(delta, 0.0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def request_id_of(response: httpx.Response) -> str | None:
|
|
75
|
+
"""Return the ``X-Request-Id`` header value, or ``None``."""
|
|
76
|
+
value: str | None = response.headers.get("x-request-id")
|
|
77
|
+
return value
|