ausdata-sdk 0.2.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.
- ausdata/__init__.py +76 -0
- ausdata/_internal/__init__.py +5 -0
- ausdata/_internal/http.py +143 -0
- ausdata/_internal/retry.py +63 -0
- ausdata/_version.py +11 -0
- ausdata/async_client.py +253 -0
- ausdata/client.py +349 -0
- ausdata/exceptions.py +101 -0
- ausdata/models.py +288 -0
- ausdata/py.typed +0 -0
- ausdata_sdk-0.2.0.dist-info/METADATA +193 -0
- ausdata_sdk-0.2.0.dist-info/RECORD +14 -0
- ausdata_sdk-0.2.0.dist-info/WHEEL +4 -0
- ausdata_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
ausdata/client.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Synchronous ``Client`` for the ausdata REST API.
|
|
2
|
+
|
|
3
|
+
Built on ``httpx.Client``. Mirrors :class:`ausdata.AsyncClient` one-to-one
|
|
4
|
+
in surface area so callers can swap freely. Method docstrings here are the
|
|
5
|
+
canonical place to learn the public surface — the async client just delegates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Literal, Self
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._internal.http import (
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
build_default_headers,
|
|
18
|
+
normalise_base_url,
|
|
19
|
+
raise_for_response,
|
|
20
|
+
resolve_api_key,
|
|
21
|
+
)
|
|
22
|
+
from ._internal.retry import RetryPolicy
|
|
23
|
+
from .exceptions import AusdataError
|
|
24
|
+
from .models import (
|
|
25
|
+
AccountResponse,
|
|
26
|
+
ApiResponse,
|
|
27
|
+
DatasetSummary,
|
|
28
|
+
EconomicDashboard,
|
|
29
|
+
HealthResponse,
|
|
30
|
+
RealWageRow,
|
|
31
|
+
WhoamiResponse,
|
|
32
|
+
parse_api_response,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _AccountNamespace:
|
|
37
|
+
"""Sub-namespace for ``client.account.*`` JWT-only methods.
|
|
38
|
+
|
|
39
|
+
Implemented as an attribute proxy so callers get a clean ``client.account.api_key()``
|
|
40
|
+
surface without flooding the top-level client with dozens of methods.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, client: "Client") -> None:
|
|
44
|
+
self._client = client
|
|
45
|
+
|
|
46
|
+
def api_key(self) -> AccountResponse:
|
|
47
|
+
"""Fetch the current account's API key (creates one if first call).
|
|
48
|
+
|
|
49
|
+
Note: the plaintext key is only returned on the very first call;
|
|
50
|
+
subsequent calls return a masked display value. Rotate the key with
|
|
51
|
+
:meth:`rotate_key` to force a new plaintext.
|
|
52
|
+
"""
|
|
53
|
+
body = self._client._request("GET", "/v1/account/api-key")
|
|
54
|
+
return AccountResponse.model_validate(body)
|
|
55
|
+
|
|
56
|
+
def rotate_key(self) -> AccountResponse:
|
|
57
|
+
"""Revoke the current key and issue a new one. Plaintext returned once.
|
|
58
|
+
|
|
59
|
+
Save the returned ``key`` immediately — you can't fetch the
|
|
60
|
+
plaintext again without rotating again.
|
|
61
|
+
"""
|
|
62
|
+
body = self._client._request(
|
|
63
|
+
"POST", "/v1/account/api-key", params={"rotate": "true"}
|
|
64
|
+
)
|
|
65
|
+
return AccountResponse.model_validate(body)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Client:
|
|
69
|
+
"""Synchronous client for the ausdata REST API.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
api_key: Bearer token. If omitted, falls back to the
|
|
73
|
+
``AUSDATA_API_KEY`` env var. Raises ``AuthenticationError``
|
|
74
|
+
when neither source is set.
|
|
75
|
+
base_url: Override the default ``https://api.ausdata.io``. Useful
|
|
76
|
+
for local development against a staging instance.
|
|
77
|
+
timeout: Per-request timeout in seconds. Defaults to 30s.
|
|
78
|
+
max_retries: Maximum retry attempts on 5xx / 429 / network errors.
|
|
79
|
+
Defaults to 3.
|
|
80
|
+
retry_backoff_factor: Multiplier for exponential backoff between
|
|
81
|
+
retries. Defaults to 2.0 (so delays go 1s, 2s, 4s).
|
|
82
|
+
transport: Optional ``httpx.BaseTransport`` for advanced use
|
|
83
|
+
(mocking in tests, custom DNS, etc.).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
api_key: str | None = None,
|
|
89
|
+
*,
|
|
90
|
+
base_url: str | None = None,
|
|
91
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
92
|
+
max_retries: int = 3,
|
|
93
|
+
retry_backoff_factor: float = 2.0,
|
|
94
|
+
transport: httpx.BaseTransport | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
resolved_key = resolve_api_key(api_key)
|
|
97
|
+
self._base_url = normalise_base_url(base_url)
|
|
98
|
+
self._retry_policy = RetryPolicy(
|
|
99
|
+
max_retries=max_retries,
|
|
100
|
+
backoff_factor=retry_backoff_factor,
|
|
101
|
+
)
|
|
102
|
+
self._httpx = httpx.Client(
|
|
103
|
+
base_url=self._base_url,
|
|
104
|
+
headers=build_default_headers(resolved_key),
|
|
105
|
+
timeout=timeout,
|
|
106
|
+
transport=transport,
|
|
107
|
+
)
|
|
108
|
+
self.account = _AccountNamespace(self)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Context manager — encourage explicit cleanup of the connection pool.
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> Self:
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc: Any) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Release the underlying httpx connection pool."""
|
|
122
|
+
self._httpx.close()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Endpoint methods
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def health(self) -> HealthResponse:
|
|
129
|
+
"""``GET /v1/health`` — service status; doesn't consume your quota."""
|
|
130
|
+
body = self._request("GET", "/v1/health")
|
|
131
|
+
return HealthResponse.model_validate(body)
|
|
132
|
+
|
|
133
|
+
def whoami(self) -> WhoamiResponse:
|
|
134
|
+
"""``GET /v1/whoami`` — verify credentials and see your tier/usage.
|
|
135
|
+
|
|
136
|
+
Works for both API-key and JWT callers. Never includes the plaintext
|
|
137
|
+
key — safe to print or log.
|
|
138
|
+
"""
|
|
139
|
+
body = self._request("GET", "/v1/whoami")
|
|
140
|
+
return WhoamiResponse.model_validate(body)
|
|
141
|
+
|
|
142
|
+
def search_datasets(
|
|
143
|
+
self,
|
|
144
|
+
q: str,
|
|
145
|
+
*,
|
|
146
|
+
source: str | list[str] | None = None,
|
|
147
|
+
limit: int = 10,
|
|
148
|
+
) -> ApiResponse[list[DatasetSummary]]:
|
|
149
|
+
"""``GET /v1/search-datasets`` — fan-out search across all 9 sources.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
q: Plain-English query, 2-200 chars.
|
|
153
|
+
source: Optional restriction to specific sources. Accepts a
|
|
154
|
+
comma-separated string or a list (e.g. ``["abs", "rba"]``).
|
|
155
|
+
limit: Max results (1-50, default 10).
|
|
156
|
+
"""
|
|
157
|
+
params: dict[str, Any] = {"q": q, "limit": limit}
|
|
158
|
+
if source is not None:
|
|
159
|
+
params["source"] = (
|
|
160
|
+
source if isinstance(source, str) else ",".join(source)
|
|
161
|
+
)
|
|
162
|
+
body = self._request("GET", "/v1/search-datasets", params=params)
|
|
163
|
+
return parse_api_response(body, data_list_model=DatasetSummary)
|
|
164
|
+
|
|
165
|
+
def real_wages(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
start: str = "2019-Q1",
|
|
169
|
+
end: str | None = None,
|
|
170
|
+
seasonal_adjustment: Literal[
|
|
171
|
+
"original", "trend", "seasonally_adjusted"
|
|
172
|
+
] = "trend",
|
|
173
|
+
) -> ApiResponse[list[RealWageRow]]:
|
|
174
|
+
"""``GET /v1/real-wages`` — WPI YoY minus CPI YoY quarterly series.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
start: Quarter start, ``YYYY`` or ``YYYY-Qn``. Bare years coerce
|
|
178
|
+
to Q1 server-side.
|
|
179
|
+
end: Quarter end. Defaults to the latest published quarter.
|
|
180
|
+
seasonal_adjustment: Match the ABS methodology you want.
|
|
181
|
+
"""
|
|
182
|
+
params: dict[str, Any] = {
|
|
183
|
+
"start": start,
|
|
184
|
+
"seasonal_adjustment": seasonal_adjustment,
|
|
185
|
+
}
|
|
186
|
+
if end is not None:
|
|
187
|
+
params["end"] = end
|
|
188
|
+
body = self._request("GET", "/v1/real-wages", params=params)
|
|
189
|
+
return parse_api_response(body, data_list_model=RealWageRow)
|
|
190
|
+
|
|
191
|
+
def economic_dashboard(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
period: str = "latest",
|
|
195
|
+
) -> ApiResponse[EconomicDashboard]:
|
|
196
|
+
"""``GET /v1/economic-dashboard`` — five headline macro indicators.
|
|
197
|
+
|
|
198
|
+
Composes RBA F1.1 cash rate + ABS CPI / LF / WPI / LEND_HOUSING.
|
|
199
|
+
"""
|
|
200
|
+
body = self._request(
|
|
201
|
+
"GET", "/v1/economic-dashboard", params={"period": period}
|
|
202
|
+
)
|
|
203
|
+
return parse_api_response(body, data_model=EconomicDashboard)
|
|
204
|
+
|
|
205
|
+
def list_datasets(self, source: str) -> dict[str, Any]:
|
|
206
|
+
"""``GET /v1/datasets/{source}`` — enumerate curated datasets for a source.
|
|
207
|
+
|
|
208
|
+
Useful for surveying what's available before drilling in. Pairs with
|
|
209
|
+
:meth:`describe` and :meth:`get_data` for the full discover →
|
|
210
|
+
introspect → fetch loop.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
|
|
214
|
+
``asic``, ``aemo``, ``wgea``.
|
|
215
|
+
|
|
216
|
+
Example::
|
|
217
|
+
|
|
218
|
+
ds = client.list_datasets("abs")
|
|
219
|
+
for d in ds["data"]:
|
|
220
|
+
print(d["id"], "→", d.get("name"))
|
|
221
|
+
"""
|
|
222
|
+
return self._request("GET", f"/v1/datasets/{source}")
|
|
223
|
+
|
|
224
|
+
def describe(self, source: str, dataset_id: str) -> dict[str, Any]:
|
|
225
|
+
"""``GET /v1/describe/{source}/{dataset_id}`` — schema introspection.
|
|
226
|
+
|
|
227
|
+
Returns the dataset's dimensions, valid filters, valid values per
|
|
228
|
+
filter, units, and source links. Call this after a 400 'Unknown
|
|
229
|
+
filter' / 'Unknown value' from :meth:`get_data` to discover the
|
|
230
|
+
correct shape.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
|
|
234
|
+
``asic``, ``aemo``, ``wgea``.
|
|
235
|
+
dataset_id: Dataset ID from search results (e.g. ``"LF"``,
|
|
236
|
+
``"F1.1"``). Source-prefixed forms like ``"abs.LF"`` also
|
|
237
|
+
accepted — the prefix is stripped server-side.
|
|
238
|
+
|
|
239
|
+
Example::
|
|
240
|
+
|
|
241
|
+
schema = client.describe("abs", "LF")
|
|
242
|
+
for dim in schema["data"]["dimensions"]:
|
|
243
|
+
print(dim["name"], "→", [v["key"] for v in dim["values"]])
|
|
244
|
+
"""
|
|
245
|
+
return self._request(
|
|
246
|
+
"GET", f"/v1/describe/{source}/{dataset_id}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def get_data(
|
|
250
|
+
self,
|
|
251
|
+
source: str,
|
|
252
|
+
dataset_id: str,
|
|
253
|
+
*,
|
|
254
|
+
start: str | None = None,
|
|
255
|
+
end: str | None = None,
|
|
256
|
+
limit: int = 100,
|
|
257
|
+
format: Literal["json", "csv"] = "json",
|
|
258
|
+
**filters: Any,
|
|
259
|
+
) -> ApiResponse[list[dict[str, Any]]]:
|
|
260
|
+
"""``GET /v1/data/{source}/{dataset_id}`` — generic data passthrough.
|
|
261
|
+
|
|
262
|
+
Fetches data from any of the 9 sister MCPs. Discover IDs with
|
|
263
|
+
:meth:`search_datasets`.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
|
|
267
|
+
``asic``, ``aemo``, ``au_weather``, ``wgea``.
|
|
268
|
+
dataset_id: Dataset ID from search results (e.g. ``"CPI"``,
|
|
269
|
+
``"F1.1"``, ``"ADI_KEY_STATS"``).
|
|
270
|
+
start: Inclusive start period. Format varies by source —
|
|
271
|
+
``YYYY-Q1`` quarterly, ``YYYY-MM`` monthly, ``YYYY`` annual.
|
|
272
|
+
end: Inclusive end period (same format as ``start``).
|
|
273
|
+
limit: Max rows (1-1000, default 100).
|
|
274
|
+
format: ``"json"`` (default) or ``"csv"``.
|
|
275
|
+
**filters: Source-specific dimension filters forwarded as query
|
|
276
|
+
params (e.g. ``region="australia"``, ``measure="change_year"``).
|
|
277
|
+
|
|
278
|
+
Example::
|
|
279
|
+
|
|
280
|
+
cpi = client.get_data("abs", "CPI", region="australia",
|
|
281
|
+
measure="change_year", limit=12)
|
|
282
|
+
"""
|
|
283
|
+
params: dict[str, Any] = {"limit": limit, "format": format, **filters}
|
|
284
|
+
if start is not None:
|
|
285
|
+
params["start"] = start
|
|
286
|
+
if end is not None:
|
|
287
|
+
params["end"] = end
|
|
288
|
+
body = self._request("GET", f"/v1/data/{source}/{dataset_id}", params=params)
|
|
289
|
+
return parse_api_response(body)
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
# Request execution + retry loop
|
|
293
|
+
# ------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def _request(
|
|
296
|
+
self,
|
|
297
|
+
method: str,
|
|
298
|
+
path: str,
|
|
299
|
+
*,
|
|
300
|
+
params: dict[str, Any] | None = None,
|
|
301
|
+
json: Any | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Execute one request, retrying transient failures per the policy."""
|
|
304
|
+
attempt = 0
|
|
305
|
+
while True:
|
|
306
|
+
try:
|
|
307
|
+
response = self._httpx.request(
|
|
308
|
+
method, path, params=params, json=json
|
|
309
|
+
)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
if (
|
|
312
|
+
self._retry_policy.should_retry_exception(exc)
|
|
313
|
+
and attempt < self._retry_policy.max_retries
|
|
314
|
+
):
|
|
315
|
+
attempt += 1
|
|
316
|
+
time.sleep(self._retry_policy.backoff_seconds(attempt))
|
|
317
|
+
continue
|
|
318
|
+
raise AusdataError(
|
|
319
|
+
f"Transport error contacting ausdata API: {exc}"
|
|
320
|
+
) from exc
|
|
321
|
+
|
|
322
|
+
if response.is_success:
|
|
323
|
+
try:
|
|
324
|
+
return response.json()
|
|
325
|
+
except ValueError as exc:
|
|
326
|
+
raise AusdataError(
|
|
327
|
+
"API returned a non-JSON 2xx response.",
|
|
328
|
+
status_code=response.status_code,
|
|
329
|
+
) from exc
|
|
330
|
+
|
|
331
|
+
if (
|
|
332
|
+
self._retry_policy.should_retry_status(response.status_code)
|
|
333
|
+
and attempt < self._retry_policy.max_retries
|
|
334
|
+
):
|
|
335
|
+
attempt += 1
|
|
336
|
+
if response.status_code == 429:
|
|
337
|
+
wait = self._retry_policy.retry_after_seconds(response) or (
|
|
338
|
+
self._retry_policy.backoff_seconds(attempt)
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
wait = self._retry_policy.backoff_seconds(attempt)
|
|
342
|
+
time.sleep(wait)
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
# Out of retries — let raise_for_response translate to a typed error.
|
|
346
|
+
raise_for_response(response)
|
|
347
|
+
# raise_for_response always raises on non-2xx, but keep mypy happy.
|
|
348
|
+
return {} # pragma: no cover
|
|
349
|
+
# Unreachable — loop only exits via return or raise.
|
ausdata/exceptions.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Exception hierarchy for the ausdata SDK.
|
|
2
|
+
|
|
3
|
+
All errors inherit from :class:`AusdataError` so callers can do a single
|
|
4
|
+
``except AusdataError`` if they don't care about specifics. Each specific
|
|
5
|
+
class carries the API's error message plus the actionable ``hint`` returned
|
|
6
|
+
by the server when present (see ``ErrorDetail`` in ausdata-api).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AusdataError(Exception):
|
|
15
|
+
"""Base class for every error raised by the ausdata SDK.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
message: Human-readable error string.
|
|
19
|
+
status_code: HTTP status code that caused the error (None for
|
|
20
|
+
client-side errors like missing API key).
|
|
21
|
+
hint: Optional "Try X" guidance returned by the API. Surfaced
|
|
22
|
+
prominently when present.
|
|
23
|
+
response_body: Raw JSON body from the API, if parsed successfully.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int | None = None,
|
|
31
|
+
hint: str | None = None,
|
|
32
|
+
response_body: dict[str, Any] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.message = message
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.hint = hint
|
|
37
|
+
self.response_body = response_body
|
|
38
|
+
full = message
|
|
39
|
+
if hint:
|
|
40
|
+
full = f"{message} (hint: {hint})"
|
|
41
|
+
super().__init__(full)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthenticationError(AusdataError):
|
|
45
|
+
"""401 — API key is missing, malformed, or revoked.
|
|
46
|
+
|
|
47
|
+
Check your ``AUSDATA_API_KEY`` env var or the ``api_key`` argument
|
|
48
|
+
passed to ``Client(...)``. Rotate the key in the dashboard if you
|
|
49
|
+
suspect it has been compromised.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PermissionError(AusdataError): # noqa: A001 — intentional shadow of builtin
|
|
54
|
+
"""403 — your tier doesn't grant access to this endpoint or feature.
|
|
55
|
+
|
|
56
|
+
The ``hint`` typically names the tier required (e.g. "Upgrade to
|
|
57
|
+
ANALYST at https://ausdata.io/pricing").
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitError(AusdataError):
|
|
62
|
+
"""429 — monthly call quota exceeded, or per-minute burst limit hit.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
retry_after: Seconds to wait before retrying, parsed from the
|
|
66
|
+
``Retry-After`` response header. ``None`` if absent.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
message: str,
|
|
72
|
+
*,
|
|
73
|
+
retry_after: float | None = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.retry_after = retry_after
|
|
77
|
+
super().__init__(message, **kwargs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ValidationError(AusdataError):
|
|
81
|
+
"""400 — request rejected by the API.
|
|
82
|
+
|
|
83
|
+
Common causes: malformed period strings, unknown dataset IDs,
|
|
84
|
+
invalid enum values. The ``hint`` usually suggests the fix.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UpstreamError(AusdataError):
|
|
89
|
+
"""502/503 — a sister data source (ABS, RBA, etc.) is failing upstream.
|
|
90
|
+
|
|
91
|
+
Retries already happened automatically before this was raised. The
|
|
92
|
+
API may serve a stale cached response on subsequent calls (look for
|
|
93
|
+
``meta.stale=True``).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AusdataServerError(AusdataError):
|
|
98
|
+
"""5xx — generic server-side failure not covered by :class:`UpstreamError`.
|
|
99
|
+
|
|
100
|
+
Raised after the SDK's automatic retries are exhausted.
|
|
101
|
+
"""
|