pgbeam 0.2.1__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.
- pgbeam/__init__.py +47 -0
- pgbeam/_client.py +157 -0
- pgbeam/_transport.py +461 -0
- pgbeam/errors.py +114 -0
- pgbeam/models.py +2949 -0
- pgbeam/operations.py +527 -0
- pgbeam/py.typed +0 -0
- pgbeam/services.py +6326 -0
- pgbeam-0.2.1.dist-info/METADATA +190 -0
- pgbeam-0.2.1.dist-info/RECORD +12 -0
- pgbeam-0.2.1.dist-info/WHEEL +4 -0
- pgbeam-0.2.1.dist-info/licenses/LICENSE +190 -0
pgbeam/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Python SDK for the PgBeam API.
|
|
2
|
+
|
|
3
|
+
from pgbeam import PgBeamClient
|
|
4
|
+
|
|
5
|
+
with PgBeamClient(token="pgb_...") as client:
|
|
6
|
+
projects = client.projects.list_projects(org_id="org_123")
|
|
7
|
+
for project in projects["projects"]:
|
|
8
|
+
print(project["id"], project["name"])
|
|
9
|
+
|
|
10
|
+
Every request and response body is a ``TypedDict`` in :mod:`pgbeam.models`, so a
|
|
11
|
+
type checker knows the shape of what comes back without anything having to be
|
|
12
|
+
unwrapped first. The async client is the same surface with ``await`` in front of
|
|
13
|
+
it:
|
|
14
|
+
|
|
15
|
+
from pgbeam import AsyncPgBeamClient
|
|
16
|
+
|
|
17
|
+
async with AsyncPgBeamClient(token="pgb_...") as client:
|
|
18
|
+
projects = await client.projects.list_projects(org_id="org_123")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from . import models as models
|
|
24
|
+
from ._client import BASE_URL_ENV_VAR, DEFAULT_BASE_URL, TOKEN_ENV_VARS
|
|
25
|
+
from ._transport import RETRYABLE_STATUS, RetryConfig
|
|
26
|
+
from .errors import ApiError, NetworkError, PgBeamError, describe_error, extract_message
|
|
27
|
+
from .operations import OPERATIONS_BY_PATH, OPERATIONS_BY_TAG, OperationMeta
|
|
28
|
+
from .services import AsyncPgBeamClient, PgBeamClient
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BASE_URL_ENV_VAR",
|
|
32
|
+
"DEFAULT_BASE_URL",
|
|
33
|
+
"OPERATIONS_BY_PATH",
|
|
34
|
+
"OPERATIONS_BY_TAG",
|
|
35
|
+
"RETRYABLE_STATUS",
|
|
36
|
+
"TOKEN_ENV_VARS",
|
|
37
|
+
"ApiError",
|
|
38
|
+
"AsyncPgBeamClient",
|
|
39
|
+
"NetworkError",
|
|
40
|
+
"OperationMeta",
|
|
41
|
+
"PgBeamClient",
|
|
42
|
+
"PgBeamError",
|
|
43
|
+
"RetryConfig",
|
|
44
|
+
"describe_error",
|
|
45
|
+
"extract_message",
|
|
46
|
+
"models",
|
|
47
|
+
]
|
pgbeam/_client.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Client construction: credentials, base URL, retries, shutdown.
|
|
2
|
+
|
|
3
|
+
The service attributes and the methods on them are generated from the OpenAPI
|
|
4
|
+
contract and live in ``services.py``. What is here is everything that is a
|
|
5
|
+
decision rather than a derivation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
if sys.version_info >= (3, 11):
|
|
18
|
+
from typing import Self
|
|
19
|
+
else: # pragma: no cover
|
|
20
|
+
from typing_extensions import Self
|
|
21
|
+
|
|
22
|
+
from ._transport import DEFAULT_TIMEOUT_MS, AsyncTransport, RetryConfig, Transport
|
|
23
|
+
|
|
24
|
+
__all__ = ["DEFAULT_BASE_URL", "BaseAsyncClient", "BaseClient"]
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.pgbeam.com"
|
|
27
|
+
|
|
28
|
+
#: Read in order, first non-empty wins. ``PGBEAM_API_KEY`` is canonical and is
|
|
29
|
+
#: what the CLI and the Terraform, Crossplane and Pulumi providers read; the
|
|
30
|
+
#: other two are accepted so one credential in the environment works everywhere.
|
|
31
|
+
TOKEN_ENV_VARS = ("PGBEAM_API_KEY", "PGBEAM_TOKEN", "PGBEAM_API_TOKEN")
|
|
32
|
+
|
|
33
|
+
BASE_URL_ENV_VAR = "PGBEAM_API_URL"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _package_version() -> str:
|
|
37
|
+
try:
|
|
38
|
+
return version("pgbeam")
|
|
39
|
+
except PackageNotFoundError: # pragma: no cover - only when run from a source tree
|
|
40
|
+
return "0.0.0+unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _env_token() -> str | None:
|
|
44
|
+
for name in TOKEN_ENV_VARS:
|
|
45
|
+
value = os.environ.get(name)
|
|
46
|
+
if value:
|
|
47
|
+
return value
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_base_url(base_url: str | None) -> str:
|
|
52
|
+
return base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _user_agent() -> str:
|
|
56
|
+
return f"pgbeam-python/{_package_version()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BaseClient:
|
|
60
|
+
"""Shared construction for the blocking client.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
token: An API key, or a callable returning one. A callable is resolved
|
|
64
|
+
once per request, under the same timeout as the request itself, so a
|
|
65
|
+
credential service that stops answering cannot stall a call
|
|
66
|
+
indefinitely. Omit it to read ``PGBEAM_API_KEY``, ``PGBEAM_TOKEN`` or
|
|
67
|
+
``PGBEAM_API_TOKEN`` from the environment.
|
|
68
|
+
base_url: Where the API lives. Defaults to ``PGBEAM_API_URL`` if set,
|
|
69
|
+
otherwise ``https://api.pgbeam.com``.
|
|
70
|
+
timeout_ms: Per-attempt timeout. 0 disables it, which leaves the request
|
|
71
|
+
at the mercy of the platform's own socket timeouts.
|
|
72
|
+
retry: Retry policy. Defaults to five retries with jittered exponential
|
|
73
|
+
backoff on 408, 429, 502, 503 and 504. Pass
|
|
74
|
+
``RetryConfig(max_retries=0)`` to disable retrying.
|
|
75
|
+
headers: Extra headers sent on every request.
|
|
76
|
+
http_client: An ``httpx.Client`` to use instead of one of this client's
|
|
77
|
+
own. Supply one to share a connection pool, a proxy or a custom
|
|
78
|
+
transport. A supplied client is not closed by ``close()``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
token: str | Callable[[], str | None] | None = None,
|
|
85
|
+
base_url: str | None = None,
|
|
86
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
87
|
+
retry: RetryConfig | None = None,
|
|
88
|
+
headers: Mapping[str, str] | None = None,
|
|
89
|
+
http_client: httpx.Client | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._transport = Transport(
|
|
92
|
+
base_url=_resolve_base_url(base_url),
|
|
93
|
+
token=token if token is not None else _env_token(),
|
|
94
|
+
timeout_ms=timeout_ms,
|
|
95
|
+
retry=retry,
|
|
96
|
+
headers=headers,
|
|
97
|
+
user_agent=_user_agent(),
|
|
98
|
+
http_client=http_client,
|
|
99
|
+
)
|
|
100
|
+
self._bind_services()
|
|
101
|
+
|
|
102
|
+
def _bind_services(self) -> None:
|
|
103
|
+
"""Attach the generated service objects. Overridden in ``services.py``."""
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Release the underlying connection pool."""
|
|
107
|
+
self._transport.close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> Self:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *exc: object) -> None:
|
|
113
|
+
self.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class BaseAsyncClient:
|
|
117
|
+
"""Shared construction for the asyncio client.
|
|
118
|
+
|
|
119
|
+
Takes the same arguments as :class:`BaseClient`, with two differences: the
|
|
120
|
+
token callable may return an awaitable, and the HTTP client is an
|
|
121
|
+
``httpx.AsyncClient``. Close it with ``await client.aclose()``, or use it as
|
|
122
|
+
an async context manager.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
token: str | Callable[[], str | Awaitable[str | None] | None] | None = None,
|
|
129
|
+
base_url: str | None = None,
|
|
130
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
131
|
+
retry: RetryConfig | None = None,
|
|
132
|
+
headers: Mapping[str, str] | None = None,
|
|
133
|
+
http_client: httpx.AsyncClient | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._transport = AsyncTransport(
|
|
136
|
+
base_url=_resolve_base_url(base_url),
|
|
137
|
+
token=token if token is not None else _env_token(),
|
|
138
|
+
timeout_ms=timeout_ms,
|
|
139
|
+
retry=retry,
|
|
140
|
+
headers=headers,
|
|
141
|
+
user_agent=_user_agent(),
|
|
142
|
+
http_client=http_client,
|
|
143
|
+
)
|
|
144
|
+
self._bind_services()
|
|
145
|
+
|
|
146
|
+
def _bind_services(self) -> None:
|
|
147
|
+
"""Attach the generated service objects. Overridden in ``services.py``."""
|
|
148
|
+
|
|
149
|
+
async def aclose(self) -> None:
|
|
150
|
+
"""Release the underlying connection pool."""
|
|
151
|
+
await self._transport.aclose()
|
|
152
|
+
|
|
153
|
+
async def __aenter__(self) -> Self:
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
157
|
+
await self.aclose()
|
pgbeam/_transport.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""HTTP transport: one request, retried and bounded.
|
|
2
|
+
|
|
3
|
+
This is the half of the SDK that is not generated, and it is a deliberate port
|
|
4
|
+
of the TypeScript SDK's ``utils/fetcher.ts`` rather than an independent design.
|
|
5
|
+
The two clients agree on which statuses are worth retrying, how long to wait
|
|
6
|
+
between attempts, when to stop waiting, when an idempotency key is attached, and
|
|
7
|
+
what a failure is called. A team running both should not have to learn the
|
|
8
|
+
difference twice.
|
|
9
|
+
|
|
10
|
+
The bounds, in the order they bite:
|
|
11
|
+
|
|
12
|
+
* ``timeout_ms`` caps one attempt. It defaults to 30 seconds, which is long
|
|
13
|
+
enough for the slowest legitimate call (an audit-log CSV export) and short
|
|
14
|
+
enough that a black-holed connection fails in a sane time.
|
|
15
|
+
* ``RetryConfig.max_retries`` caps how many attempts there are.
|
|
16
|
+
* ``RetryConfig.total_budget_ms`` caps the whole call, measured from the first
|
|
17
|
+
attempt and including time spent in requests. A retry that would land past the
|
|
18
|
+
budget is not made, so a long backoff ladder against a service that is down
|
|
19
|
+
cannot outlive it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import json
|
|
27
|
+
import random
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from email.utils import parsedate_to_datetime
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import quote
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
|
|
40
|
+
from .errors import ApiError, NetworkError
|
|
41
|
+
|
|
42
|
+
__all__ = ["AsyncTransport", "RetryConfig", "Transport"]
|
|
43
|
+
|
|
44
|
+
#: Statuses worth trying again. Everything else is the server's considered
|
|
45
|
+
#: answer, and repeating the request will get the same one.
|
|
46
|
+
RETRYABLE_STATUS = frozenset({408, 429, 502, 503, 504})
|
|
47
|
+
|
|
48
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
49
|
+
|
|
50
|
+
#: Methods where a retry could otherwise duplicate work. PUT and DELETE are
|
|
51
|
+
#: idempotent by definition and need no key; GET changes nothing.
|
|
52
|
+
MUTATING_METHODS = frozenset({"POST", "PATCH"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RetryConfig:
|
|
57
|
+
"""How hard to try again, and when to stop.
|
|
58
|
+
|
|
59
|
+
The defaults are the TypeScript SDK's. ``max_retries=0`` disables retrying
|
|
60
|
+
without disabling anything else.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
max_retries: int = 5
|
|
64
|
+
initial_delay_ms: int = 500
|
|
65
|
+
max_delay_ms: int = 30_000
|
|
66
|
+
#: Attach an ``Idempotency-Key`` to retried POST and PATCH requests, so a
|
|
67
|
+
#: retry of a request the server already accepted is not a second write.
|
|
68
|
+
idempotency_keys: bool = True
|
|
69
|
+
#: Ceiling for the whole call, measured from the first attempt.
|
|
70
|
+
total_budget_ms: int = 120_000
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
NO_RETRY = RetryConfig(max_retries=0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _backoff_ms(attempt: int, config: RetryConfig) -> float:
|
|
77
|
+
"""Exponential backoff with jitter, capped at ``max_delay_ms``."""
|
|
78
|
+
delay: float = min(config.initial_delay_ms * (2**attempt), config.max_delay_ms)
|
|
79
|
+
return delay * (0.5 + random.random())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _out_of_budget(started_at: float, delay_ms: float, config: RetryConfig) -> bool:
|
|
83
|
+
"""Whether waiting ``delay_ms`` and trying again would land past the budget."""
|
|
84
|
+
if config.total_budget_ms <= 0:
|
|
85
|
+
return False
|
|
86
|
+
elapsed_ms = (time.monotonic() - started_at) * 1000
|
|
87
|
+
return elapsed_ms + delay_ms >= config.total_budget_ms
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _retry_after_ms(response: httpx.Response) -> float | None:
|
|
91
|
+
"""``Retry-After`` in milliseconds, honouring both forms the RFC allows."""
|
|
92
|
+
header = response.headers.get("Retry-After")
|
|
93
|
+
if not header:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
seconds = float(header)
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
else:
|
|
100
|
+
return seconds * 1000 if seconds >= 0 else None
|
|
101
|
+
try:
|
|
102
|
+
when = parsedate_to_datetime(header)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
now = dt.datetime.now(tz=when.tzinfo or dt.timezone.utc)
|
|
106
|
+
delta_ms = (when - now).total_seconds() * 1000
|
|
107
|
+
return max(delta_ms, 0.0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _query_value(value: object) -> str:
|
|
111
|
+
"""Render a query parameter the way the API reads it.
|
|
112
|
+
|
|
113
|
+
Booleans go over the wire as ``true``/``false``, not Python's ``True``.
|
|
114
|
+
Getting this wrong is the kind of bug that only shows up as a filter that
|
|
115
|
+
quietly matches nothing.
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(value, bool):
|
|
118
|
+
return "true" if value else "false"
|
|
119
|
+
return str(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _build_url(path: str, path_params: Mapping[str, str] | None) -> str:
|
|
123
|
+
if not path_params:
|
|
124
|
+
return path
|
|
125
|
+
url = path
|
|
126
|
+
for key, value in path_params.items():
|
|
127
|
+
url = url.replace("{" + key + "}", quote(str(value), safe=""))
|
|
128
|
+
return url
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_query(query: Mapping[str, object] | None) -> dict[str, str]:
|
|
132
|
+
if not query:
|
|
133
|
+
return {}
|
|
134
|
+
return {key: _query_value(value) for key, value in query.items() if value is not None}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
138
|
+
"""Decode a successful response.
|
|
139
|
+
|
|
140
|
+
A 204 is ``None``. JSON is decoded. Anything else comes back as text, after
|
|
141
|
+
one opportunistic JSON attempt in case the server mislabelled it, because
|
|
142
|
+
the audit-log export is genuinely ``text/csv`` and a caller wants the bytes
|
|
143
|
+
rather than ``None``.
|
|
144
|
+
"""
|
|
145
|
+
if response.status_code == 204:
|
|
146
|
+
return None
|
|
147
|
+
content_type = response.headers.get("content-type", "")
|
|
148
|
+
if "application/json" in content_type:
|
|
149
|
+
return response.json()
|
|
150
|
+
text = response.text
|
|
151
|
+
if not text:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
return json.loads(text)
|
|
155
|
+
except ValueError:
|
|
156
|
+
return text
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _error_body(response: httpx.Response) -> Any:
|
|
160
|
+
try:
|
|
161
|
+
return response.json()
|
|
162
|
+
except ValueError:
|
|
163
|
+
return response.text or None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _headers(
|
|
167
|
+
token: str | None,
|
|
168
|
+
has_body: bool,
|
|
169
|
+
extra: Mapping[str, str] | None,
|
|
170
|
+
user_agent: str,
|
|
171
|
+
) -> dict[str, str]:
|
|
172
|
+
headers = {"Accept": "application/json", "User-Agent": user_agent}
|
|
173
|
+
if extra:
|
|
174
|
+
headers.update(extra)
|
|
175
|
+
if token:
|
|
176
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
177
|
+
if has_body:
|
|
178
|
+
headers["Content-Type"] = "application/json"
|
|
179
|
+
return headers
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _Attempt:
|
|
183
|
+
"""Bookkeeping shared by the sync and async loops."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, method: str, url: str, timeout_ms: int) -> None:
|
|
186
|
+
self.method = method
|
|
187
|
+
self.url = url
|
|
188
|
+
self.timeout_ms = timeout_ms
|
|
189
|
+
self.started_at = time.monotonic()
|
|
190
|
+
|
|
191
|
+
def elapsed_ms(self) -> int:
|
|
192
|
+
return int((time.monotonic() - self.started_at) * 1000)
|
|
193
|
+
|
|
194
|
+
def network_error(
|
|
195
|
+
self, attempts: int, timed_out: bool, cause: BaseException | None
|
|
196
|
+
) -> NetworkError:
|
|
197
|
+
return NetworkError(
|
|
198
|
+
method=self.method,
|
|
199
|
+
url=self.url,
|
|
200
|
+
attempts=attempts,
|
|
201
|
+
elapsed_ms=self.elapsed_ms(),
|
|
202
|
+
timed_out=timed_out,
|
|
203
|
+
timeout_ms=self.timeout_ms,
|
|
204
|
+
cause=cause,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _timed_out(err: BaseException) -> bool:
|
|
209
|
+
return isinstance(err, httpx.TimeoutException)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Transport:
|
|
213
|
+
"""Blocking transport over ``httpx.Client``."""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
base_url: str,
|
|
219
|
+
token: str | Callable[[], str | None] | None = None,
|
|
220
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
221
|
+
retry: RetryConfig | None = None,
|
|
222
|
+
headers: Mapping[str, str] | None = None,
|
|
223
|
+
user_agent: str,
|
|
224
|
+
http_client: httpx.Client | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
self._token = token
|
|
227
|
+
self._timeout_ms = timeout_ms
|
|
228
|
+
self._retry = retry if retry is not None else RetryConfig()
|
|
229
|
+
self._headers = dict(headers) if headers else {}
|
|
230
|
+
self._user_agent = user_agent
|
|
231
|
+
self._owns_client = http_client is None
|
|
232
|
+
self._client = http_client or httpx.Client(base_url=base_url, follow_redirects=False)
|
|
233
|
+
self._base_url = base_url
|
|
234
|
+
self._token_pool: ThreadPoolExecutor | None = None
|
|
235
|
+
|
|
236
|
+
def close(self) -> None:
|
|
237
|
+
if self._owns_client:
|
|
238
|
+
self._client.close()
|
|
239
|
+
if self._token_pool is not None:
|
|
240
|
+
self._token_pool.shutdown(wait=False)
|
|
241
|
+
self._token_pool = None
|
|
242
|
+
|
|
243
|
+
def __enter__(self) -> Transport:
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, *exc: object) -> None:
|
|
247
|
+
self.close()
|
|
248
|
+
|
|
249
|
+
def _resolve_token(self, attempt: _Attempt) -> str | None:
|
|
250
|
+
"""Resolve a lazy token, or give up once ``timeout_ms`` has passed.
|
|
251
|
+
|
|
252
|
+
A token callable is usually a network call in disguise, so it gets the
|
|
253
|
+
same per-attempt ceiling as the request it authenticates. Without one it
|
|
254
|
+
would sit outside every bound this module applies, which is the case the
|
|
255
|
+
TypeScript SDK found the hard way: a request with no ceiling of any kind.
|
|
256
|
+
A ``timeout_ms`` of 0 disables the request timeout by documented
|
|
257
|
+
contract, so it disables this one too rather than inventing a ceiling
|
|
258
|
+
the caller turned off.
|
|
259
|
+
"""
|
|
260
|
+
token = self._token
|
|
261
|
+
if not callable(token):
|
|
262
|
+
return token
|
|
263
|
+
if self._timeout_ms <= 0:
|
|
264
|
+
return token()
|
|
265
|
+
|
|
266
|
+
if self._token_pool is None:
|
|
267
|
+
self._token_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pgbeam-token")
|
|
268
|
+
future = self._token_pool.submit(token)
|
|
269
|
+
try:
|
|
270
|
+
return future.result(timeout=self._timeout_ms / 1000)
|
|
271
|
+
except FutureTimeoutError as err:
|
|
272
|
+
# The thread is abandoned rather than killed: Python cannot
|
|
273
|
+
# interrupt it, and the caller is owed an answer now. It is reported
|
|
274
|
+
# as a timed-out NetworkError because that is what it is to a
|
|
275
|
+
# caller, and because a second try tests the same thing again.
|
|
276
|
+
raise attempt.network_error(attempts=0, timed_out=True, cause=err) from err
|
|
277
|
+
|
|
278
|
+
def request(
|
|
279
|
+
self,
|
|
280
|
+
method: str,
|
|
281
|
+
path: str,
|
|
282
|
+
*,
|
|
283
|
+
path_params: Mapping[str, str] | None = None,
|
|
284
|
+
query: Mapping[str, object] | None = None,
|
|
285
|
+
body: object | None = None,
|
|
286
|
+
) -> Any:
|
|
287
|
+
url = _build_url(path, path_params)
|
|
288
|
+
params = _build_query(query)
|
|
289
|
+
attempt_ctx = _Attempt(method, f"{self._base_url.rstrip('/')}{url}", self._timeout_ms)
|
|
290
|
+
retry = self._retry
|
|
291
|
+
|
|
292
|
+
token = self._resolve_token(attempt_ctx)
|
|
293
|
+
headers = _headers(token, body is not None, self._headers, self._user_agent)
|
|
294
|
+
|
|
295
|
+
# Generated once and reused, so every attempt at the same call carries
|
|
296
|
+
# the same key and the server can collapse the duplicates.
|
|
297
|
+
if retry.max_retries > 0 and retry.idempotency_keys and method.upper() in MUTATING_METHODS:
|
|
298
|
+
headers.setdefault("Idempotency-Key", str(uuid.uuid4()))
|
|
299
|
+
|
|
300
|
+
timeout = self._timeout_ms / 1000 if self._timeout_ms > 0 else None
|
|
301
|
+
content = json.dumps(body).encode() if body is not None else None
|
|
302
|
+
|
|
303
|
+
for attempt in range(retry.max_retries + 1):
|
|
304
|
+
answered = False
|
|
305
|
+
try:
|
|
306
|
+
response = self._client.request(
|
|
307
|
+
method,
|
|
308
|
+
url,
|
|
309
|
+
params=params or None,
|
|
310
|
+
headers=headers,
|
|
311
|
+
content=content,
|
|
312
|
+
timeout=timeout,
|
|
313
|
+
)
|
|
314
|
+
answered = True
|
|
315
|
+
|
|
316
|
+
if response.is_success:
|
|
317
|
+
return _parse_body(response)
|
|
318
|
+
|
|
319
|
+
retryable = response.status_code in RETRYABLE_STATUS and attempt < retry.max_retries
|
|
320
|
+
delay = (
|
|
321
|
+
(_retry_after_ms(response) or _backoff_ms(attempt, retry)) if retryable else 0.0
|
|
322
|
+
)
|
|
323
|
+
if not retryable or _out_of_budget(attempt_ctx.started_at, delay, retry):
|
|
324
|
+
raise ApiError(
|
|
325
|
+
response.status_code,
|
|
326
|
+
response.reason_phrase,
|
|
327
|
+
_error_body(response),
|
|
328
|
+
)
|
|
329
|
+
time.sleep(delay / 1000)
|
|
330
|
+
except ApiError:
|
|
331
|
+
raise
|
|
332
|
+
except httpx.HTTPError as err:
|
|
333
|
+
delay = _backoff_ms(attempt, retry)
|
|
334
|
+
if attempt == retry.max_retries or _out_of_budget(
|
|
335
|
+
attempt_ctx.started_at, delay, retry
|
|
336
|
+
):
|
|
337
|
+
if answered:
|
|
338
|
+
raise
|
|
339
|
+
raise attempt_ctx.network_error(
|
|
340
|
+
attempts=attempt + 1, timed_out=_timed_out(err), cause=err
|
|
341
|
+
) from err
|
|
342
|
+
time.sleep(delay / 1000)
|
|
343
|
+
|
|
344
|
+
raise RuntimeError("pgbeam transport: exhausted all retry attempts") # pragma: no cover
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class AsyncTransport:
|
|
348
|
+
"""Awaitable transport over ``httpx.AsyncClient``.
|
|
349
|
+
|
|
350
|
+
Same policy as :class:`Transport`, same defaults, same errors.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
base_url: str,
|
|
357
|
+
token: str | Callable[[], str | Awaitable[str | None] | None] | None = None,
|
|
358
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
359
|
+
retry: RetryConfig | None = None,
|
|
360
|
+
headers: Mapping[str, str] | None = None,
|
|
361
|
+
user_agent: str,
|
|
362
|
+
http_client: httpx.AsyncClient | None = None,
|
|
363
|
+
) -> None:
|
|
364
|
+
self._token = token
|
|
365
|
+
self._timeout_ms = timeout_ms
|
|
366
|
+
self._retry = retry if retry is not None else RetryConfig()
|
|
367
|
+
self._headers = dict(headers) if headers else {}
|
|
368
|
+
self._user_agent = user_agent
|
|
369
|
+
self._owns_client = http_client is None
|
|
370
|
+
self._client = http_client or httpx.AsyncClient(base_url=base_url, follow_redirects=False)
|
|
371
|
+
self._base_url = base_url
|
|
372
|
+
|
|
373
|
+
async def aclose(self) -> None:
|
|
374
|
+
if self._owns_client:
|
|
375
|
+
await self._client.aclose()
|
|
376
|
+
|
|
377
|
+
async def __aenter__(self) -> AsyncTransport:
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
381
|
+
await self.aclose()
|
|
382
|
+
|
|
383
|
+
async def _resolve_token(self, attempt: _Attempt) -> str | None:
|
|
384
|
+
token = self._token
|
|
385
|
+
if not callable(token):
|
|
386
|
+
return token
|
|
387
|
+
result = token()
|
|
388
|
+
if not isinstance(result, str) and result is not None:
|
|
389
|
+
if self._timeout_ms <= 0:
|
|
390
|
+
return await result
|
|
391
|
+
try:
|
|
392
|
+
return await asyncio.wait_for(result, timeout=self._timeout_ms / 1000)
|
|
393
|
+
except asyncio.TimeoutError as err:
|
|
394
|
+
raise attempt.network_error(attempts=0, timed_out=True, cause=err) from err
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
async def request(
|
|
398
|
+
self,
|
|
399
|
+
method: str,
|
|
400
|
+
path: str,
|
|
401
|
+
*,
|
|
402
|
+
path_params: Mapping[str, str] | None = None,
|
|
403
|
+
query: Mapping[str, object] | None = None,
|
|
404
|
+
body: object | None = None,
|
|
405
|
+
) -> Any:
|
|
406
|
+
url = _build_url(path, path_params)
|
|
407
|
+
params = _build_query(query)
|
|
408
|
+
attempt_ctx = _Attempt(method, f"{self._base_url.rstrip('/')}{url}", self._timeout_ms)
|
|
409
|
+
retry = self._retry
|
|
410
|
+
|
|
411
|
+
token = await self._resolve_token(attempt_ctx)
|
|
412
|
+
headers = _headers(token, body is not None, self._headers, self._user_agent)
|
|
413
|
+
|
|
414
|
+
if retry.max_retries > 0 and retry.idempotency_keys and method.upper() in MUTATING_METHODS:
|
|
415
|
+
headers.setdefault("Idempotency-Key", str(uuid.uuid4()))
|
|
416
|
+
|
|
417
|
+
timeout = self._timeout_ms / 1000 if self._timeout_ms > 0 else None
|
|
418
|
+
content = json.dumps(body).encode() if body is not None else None
|
|
419
|
+
|
|
420
|
+
for attempt in range(retry.max_retries + 1):
|
|
421
|
+
answered = False
|
|
422
|
+
try:
|
|
423
|
+
response = await self._client.request(
|
|
424
|
+
method,
|
|
425
|
+
url,
|
|
426
|
+
params=params or None,
|
|
427
|
+
headers=headers,
|
|
428
|
+
content=content,
|
|
429
|
+
timeout=timeout,
|
|
430
|
+
)
|
|
431
|
+
answered = True
|
|
432
|
+
|
|
433
|
+
if response.is_success:
|
|
434
|
+
return _parse_body(response)
|
|
435
|
+
|
|
436
|
+
retryable = response.status_code in RETRYABLE_STATUS and attempt < retry.max_retries
|
|
437
|
+
delay = (
|
|
438
|
+
(_retry_after_ms(response) or _backoff_ms(attempt, retry)) if retryable else 0.0
|
|
439
|
+
)
|
|
440
|
+
if not retryable or _out_of_budget(attempt_ctx.started_at, delay, retry):
|
|
441
|
+
raise ApiError(
|
|
442
|
+
response.status_code,
|
|
443
|
+
response.reason_phrase,
|
|
444
|
+
_error_body(response),
|
|
445
|
+
)
|
|
446
|
+
await asyncio.sleep(delay / 1000)
|
|
447
|
+
except ApiError:
|
|
448
|
+
raise
|
|
449
|
+
except httpx.HTTPError as err:
|
|
450
|
+
delay = _backoff_ms(attempt, retry)
|
|
451
|
+
if attempt == retry.max_retries or _out_of_budget(
|
|
452
|
+
attempt_ctx.started_at, delay, retry
|
|
453
|
+
):
|
|
454
|
+
if answered:
|
|
455
|
+
raise
|
|
456
|
+
raise attempt_ctx.network_error(
|
|
457
|
+
attempts=attempt + 1, timed_out=_timed_out(err), cause=err
|
|
458
|
+
) from err
|
|
459
|
+
await asyncio.sleep(delay / 1000)
|
|
460
|
+
|
|
461
|
+
raise RuntimeError("pgbeam transport: exhausted all retry attempts") # pragma: no cover
|