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 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