pkmnprices 1.0.0__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: pip install -e ".[dev]"
19
+ - run: mypy src/pkmnprices
20
+ - run: pytest -q
21
+
22
+ publish:
23
+ needs: test
24
+ runs-on: ubuntu-latest
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.12"
32
+ - run: pip install build
33
+ - run: python -m build
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pkmn Prices
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkmnprices
3
+ Version: 1.0.0
4
+ Summary: Python client for the Pkmn Prices API
5
+ Project-URL: Homepage, https://pkmnprices.com
6
+ Author: Pkmn Prices
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: api-client,cardmarket,ebay,pokemon,pricing,sdk,tcg,tcgplayer
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Provides-Extra: dev
15
+ Requires-Dist: mypy>=1.10; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Requires-Dist: ruff>=0.5; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pkmnprices
22
+
23
+ Python client for the [Pkmn Prices API](https://pkmnprices.com). Pokemon TCG card pricing from TCGPlayer, Cardmarket, and eBay.
24
+
25
+ Sync and async clients, both built on httpx. Typed responses, typed errors, and iterators that page through results for you. Python 3.10+.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install pkmnprices
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from pkmnprices import PkmnPrices
37
+
38
+ client = PkmnPrices("pk_your_key_here")
39
+
40
+ page = client.cards.list(name="charizard", per_page=10)
41
+
42
+ card = client.cards.get(page.data[0].id)
43
+ for price in card.prices:
44
+ symbol = "€" if price.currency == "EUR" else "$"
45
+ print(f"{price.source}: {symbol}{price.market_price}")
46
+
47
+ client.close()
48
+ ```
49
+
50
+ The client is also a context manager:
51
+
52
+ ```python
53
+ with PkmnPrices("pk_...") as client:
54
+ health = client.health()
55
+ ```
56
+
57
+ ### Async
58
+
59
+ ```python
60
+ import asyncio
61
+ from pkmnprices import AsyncPkmnPrices
62
+
63
+ async def main():
64
+ async with AsyncPkmnPrices("pk_...") as client:
65
+ page = await client.cards.list(name="charizard")
66
+ async for card in client.cards.iterate(name="charizard"):
67
+ print(card.name)
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ Get an API key from <https://pkmnprices.com/dashboard>.
73
+
74
+ ## Options
75
+
76
+ ```python
77
+ PkmnPrices(
78
+ "pk_...", # API key, sent as the x-api-key header
79
+ max_retries=2, # retries on 429 rate limits and 5xx/network errors
80
+ timeout=30.0, # per-request timeout in seconds
81
+ )
82
+ ```
83
+
84
+ Rate-limit `429`s are retried with backoff. Credit-limit `429`s (`credit_limit_exceeded`) are not, since they don't reset until the next day.
85
+
86
+ ## Pagination
87
+
88
+ List endpoints return a `Page` (`.data`, `.pagination`). Listing endpoints (eBay/Cardmarket) return a `CursorPage`. Both resources expose iterators so you don't track pages or cursors:
89
+
90
+ ```python
91
+ for card in client.cards.iterate(name="charizard"):
92
+ print(card.name)
93
+
94
+ all_sets = client.sets.list_all(language="english")
95
+
96
+ for sale in client.cards.listings.iterate_ebay(789, grader="PSA", grade="10"):
97
+ print(sale.title, sale.price)
98
+ ```
99
+
100
+ ## Currency
101
+
102
+ Every price has a `currency` field. Pass `currency="usd"` or `currency="eur"` to filter, or leave it off to get everything your plan allows. EUR (Cardmarket) prices need a Pro plan; a free key asking for `eur` raises `ForbiddenError`.
103
+
104
+ ```python
105
+ card = client.cards.get(789, currency="usd")
106
+ ```
107
+
108
+ ## Errors
109
+
110
+ Everything raised subclasses `PkmnPricesError`, which carries `status`, `code`, `rate_limit`, and `retry_after`.
111
+
112
+ ```python
113
+ from pkmnprices import ForbiddenError, NotFoundError, RateLimitError
114
+
115
+ try:
116
+ client.cards.get(789, currency="eur")
117
+ except ForbiddenError:
118
+ ... # needs a higher plan
119
+ except NotFoundError:
120
+ ... # no such card
121
+ except RateLimitError:
122
+ ... # ran out of retries
123
+ ```
124
+
125
+ Subclasses: `BadRequestError` (400), `UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404), `ConflictError` (409), `CreditLimitError` and `RateLimitError` (429), `InternalServerError` (5xx), `APIConnectionError` (network/timeout).
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,109 @@
1
+ # pkmnprices
2
+
3
+ Python client for the [Pkmn Prices API](https://pkmnprices.com). Pokemon TCG card pricing from TCGPlayer, Cardmarket, and eBay.
4
+
5
+ Sync and async clients, both built on httpx. Typed responses, typed errors, and iterators that page through results for you. Python 3.10+.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install pkmnprices
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from pkmnprices import PkmnPrices
17
+
18
+ client = PkmnPrices("pk_your_key_here")
19
+
20
+ page = client.cards.list(name="charizard", per_page=10)
21
+
22
+ card = client.cards.get(page.data[0].id)
23
+ for price in card.prices:
24
+ symbol = "€" if price.currency == "EUR" else "$"
25
+ print(f"{price.source}: {symbol}{price.market_price}")
26
+
27
+ client.close()
28
+ ```
29
+
30
+ The client is also a context manager:
31
+
32
+ ```python
33
+ with PkmnPrices("pk_...") as client:
34
+ health = client.health()
35
+ ```
36
+
37
+ ### Async
38
+
39
+ ```python
40
+ import asyncio
41
+ from pkmnprices import AsyncPkmnPrices
42
+
43
+ async def main():
44
+ async with AsyncPkmnPrices("pk_...") as client:
45
+ page = await client.cards.list(name="charizard")
46
+ async for card in client.cards.iterate(name="charizard"):
47
+ print(card.name)
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ Get an API key from <https://pkmnprices.com/dashboard>.
53
+
54
+ ## Options
55
+
56
+ ```python
57
+ PkmnPrices(
58
+ "pk_...", # API key, sent as the x-api-key header
59
+ max_retries=2, # retries on 429 rate limits and 5xx/network errors
60
+ timeout=30.0, # per-request timeout in seconds
61
+ )
62
+ ```
63
+
64
+ Rate-limit `429`s are retried with backoff. Credit-limit `429`s (`credit_limit_exceeded`) are not, since they don't reset until the next day.
65
+
66
+ ## Pagination
67
+
68
+ List endpoints return a `Page` (`.data`, `.pagination`). Listing endpoints (eBay/Cardmarket) return a `CursorPage`. Both resources expose iterators so you don't track pages or cursors:
69
+
70
+ ```python
71
+ for card in client.cards.iterate(name="charizard"):
72
+ print(card.name)
73
+
74
+ all_sets = client.sets.list_all(language="english")
75
+
76
+ for sale in client.cards.listings.iterate_ebay(789, grader="PSA", grade="10"):
77
+ print(sale.title, sale.price)
78
+ ```
79
+
80
+ ## Currency
81
+
82
+ Every price has a `currency` field. Pass `currency="usd"` or `currency="eur"` to filter, or leave it off to get everything your plan allows. EUR (Cardmarket) prices need a Pro plan; a free key asking for `eur` raises `ForbiddenError`.
83
+
84
+ ```python
85
+ card = client.cards.get(789, currency="usd")
86
+ ```
87
+
88
+ ## Errors
89
+
90
+ Everything raised subclasses `PkmnPricesError`, which carries `status`, `code`, `rate_limit`, and `retry_after`.
91
+
92
+ ```python
93
+ from pkmnprices import ForbiddenError, NotFoundError, RateLimitError
94
+
95
+ try:
96
+ client.cards.get(789, currency="eur")
97
+ except ForbiddenError:
98
+ ... # needs a higher plan
99
+ except NotFoundError:
100
+ ... # no such card
101
+ except RateLimitError:
102
+ ... # ran out of retries
103
+ ```
104
+
105
+ Subclasses: `BadRequestError` (400), `UnauthorizedError` (401), `ForbiddenError` (403), `NotFoundError` (404), `ConflictError` (409), `CreditLimitError` and `RateLimitError` (429), `InternalServerError` (5xx), `APIConnectionError` (network/timeout).
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pkmnprices"
7
+ version = "1.0.0"
8
+ description = "Python client for the Pkmn Prices API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Pkmn Prices" }]
13
+ keywords = ["pokemon", "tcg", "pricing", "tcgplayer", "cardmarket", "ebay", "sdk", "api-client"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = ["httpx>=0.27"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://pkmnprices.com"
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.5"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/pkmnprices"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
@@ -0,0 +1,66 @@
1
+ from .client import AsyncPkmnPrices, PkmnPrices
2
+ from .errors import (
3
+ APIConnectionError,
4
+ BadRequestError,
5
+ ConflictError,
6
+ CreditLimitError,
7
+ ForbiddenError,
8
+ InternalServerError,
9
+ NotFoundError,
10
+ PkmnPricesError,
11
+ RateLimitError,
12
+ RateLimitInfo,
13
+ UnauthorizedError,
14
+ )
15
+ from .models import (
16
+ Card,
17
+ CardmarketListing,
18
+ CardSummary,
19
+ CursorInfo,
20
+ CursorPage,
21
+ EbayListing,
22
+ Health,
23
+ Page,
24
+ PageInfo,
25
+ Price,
26
+ PriceHistoryPoint,
27
+ Sealed,
28
+ SealedEbayListing,
29
+ SealedSummary,
30
+ Set,
31
+ SetRef,
32
+ )
33
+
34
+ __version__ = "1.0.0"
35
+
36
+ __all__ = [
37
+ "PkmnPrices",
38
+ "AsyncPkmnPrices",
39
+ "PkmnPricesError",
40
+ "BadRequestError",
41
+ "UnauthorizedError",
42
+ "ForbiddenError",
43
+ "NotFoundError",
44
+ "ConflictError",
45
+ "CreditLimitError",
46
+ "RateLimitError",
47
+ "InternalServerError",
48
+ "APIConnectionError",
49
+ "RateLimitInfo",
50
+ "Card",
51
+ "CardSummary",
52
+ "CardmarketListing",
53
+ "CursorInfo",
54
+ "CursorPage",
55
+ "EbayListing",
56
+ "Health",
57
+ "Page",
58
+ "PageInfo",
59
+ "Price",
60
+ "PriceHistoryPoint",
61
+ "Sealed",
62
+ "SealedEbayListing",
63
+ "SealedSummary",
64
+ "Set",
65
+ "SetRef",
66
+ ]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from .models import CursorInfo, CursorPage, Model, Page, PageInfo
7
+
8
+
9
+ @dataclass
10
+ class Request:
11
+ method: str
12
+ path: str
13
+ query: dict[str, Any] = field(default_factory=dict)
14
+ auth: bool = True
15
+
16
+
17
+ def _clean(query: dict[str, Any]) -> dict[str, Any]:
18
+ return {key: value for key, value in query.items() if value is not None}
19
+
20
+
21
+ def health() -> Request:
22
+ return Request("GET", "/health", auth=False)
23
+
24
+
25
+ def sets_list(**query: Any) -> Request:
26
+ return Request("GET", "/v1/sets", _clean(query))
27
+
28
+
29
+ def sets_get(set_id: int) -> Request:
30
+ return Request("GET", f"/v1/sets/{set_id}")
31
+
32
+
33
+ def cards_list(**query: Any) -> Request:
34
+ return Request("GET", "/v1/cards", _clean(query))
35
+
36
+
37
+ def cards_get(card_id: int, **query: Any) -> Request:
38
+ return Request("GET", f"/v1/cards/{card_id}", _clean(query))
39
+
40
+
41
+ def cards_history(card_id: int, **query: Any) -> Request:
42
+ return Request("GET", f"/v1/cards/{card_id}/prices/history", _clean(query))
43
+
44
+
45
+ def cards_listings_ebay(card_id: int, **query: Any) -> Request:
46
+ return Request("GET", f"/v1/cards/{card_id}/listings/ebay", _clean(query))
47
+
48
+
49
+ def cards_listings_cardmarket(card_id: int, **query: Any) -> Request:
50
+ return Request("GET", f"/v1/cards/{card_id}/listings/cardmarket", _clean(query))
51
+
52
+
53
+ def sealed_list(**query: Any) -> Request:
54
+ return Request("GET", "/v1/sealed", _clean(query))
55
+
56
+
57
+ def sealed_get(sealed_id: int) -> Request:
58
+ return Request("GET", f"/v1/sealed/{sealed_id}")
59
+
60
+
61
+ def sealed_history(sealed_id: int, **query: Any) -> Request:
62
+ return Request("GET", f"/v1/sealed/{sealed_id}/prices/history", _clean(query))
63
+
64
+
65
+ def sealed_listings(sealed_id: int, **query: Any) -> Request:
66
+ return Request("GET", f"/v1/sealed/{sealed_id}/listings", _clean(query))
67
+
68
+
69
+ def build_page(raw: dict[str, Any], model: type[Model]) -> Page[Any]:
70
+ items = [model.from_dict(item) for item in raw["data"]]
71
+ return Page(items, PageInfo.from_dict(raw["pagination"]))
72
+
73
+
74
+ def build_cursor_page(raw: dict[str, Any], model: type[Model]) -> CursorPage[Any]:
75
+ items = [model.from_dict(item) for item in raw["data"]]
76
+ return CursorPage(items, CursorInfo.from_dict(raw["pagination"]))
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ._endpoints import Request
11
+ from .errors import (
12
+ APIConnectionError,
13
+ PkmnPricesError,
14
+ RateLimitError,
15
+ RateLimitInfo,
16
+ create_api_error,
17
+ )
18
+
19
+ _RETRYABLE_STATUS = {500, 502, 503, 504}
20
+ _BASE_BACKOFF = 0.5
21
+ _MAX_BACKOFF = 8.0
22
+
23
+
24
+ def _int_header(value: str | None) -> int | None:
25
+ if value is None:
26
+ return None
27
+ try:
28
+ return int(value)
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ def _rate_limit(headers: httpx.Headers) -> RateLimitInfo:
34
+ return RateLimitInfo(
35
+ credits_charged=_int_header(headers.get("x-credits-charged")),
36
+ credits_limit=_int_header(headers.get("x-credits-limit")),
37
+ rate_limit=_int_header(headers.get("x-rate-limit")),
38
+ rate_remaining=_int_header(headers.get("x-rate-remaining")),
39
+ )
40
+
41
+
42
+ def _retry_after(headers: httpx.Headers) -> float | None:
43
+ raw = headers.get("retry-after")
44
+ if raw is None:
45
+ return None
46
+ try:
47
+ return float(raw)
48
+ except ValueError:
49
+ return None
50
+
51
+
52
+ def _handle(response: httpx.Response, path: str) -> Any:
53
+ rate_limit = _rate_limit(response.headers)
54
+
55
+ if response.is_success:
56
+ if not response.content:
57
+ return None
58
+ return response.json()
59
+
60
+ body: Any = None
61
+ try:
62
+ body = response.json()
63
+ except ValueError:
64
+ body = None
65
+
66
+ has_error = isinstance(body, dict) and isinstance(body.get("error"), dict)
67
+ code = body["error"]["code"] if has_error else "unknown"
68
+ message = (
69
+ body["error"]["message"]
70
+ if has_error
71
+ else f"Request to {path} failed with status {response.status_code}"
72
+ )
73
+
74
+ raise create_api_error(
75
+ status=response.status_code,
76
+ code=code,
77
+ message=message,
78
+ rate_limit=rate_limit,
79
+ retry_after=_retry_after(response.headers),
80
+ )
81
+
82
+
83
+ def _is_retryable(error: Exception) -> bool:
84
+ if isinstance(error, (RateLimitError, APIConnectionError)):
85
+ return True
86
+ if isinstance(error, PkmnPricesError):
87
+ return error.status in _RETRYABLE_STATUS
88
+ return False
89
+
90
+
91
+ def _backoff(attempt: int, retry_after: float | None) -> float:
92
+ if retry_after is not None:
93
+ return retry_after
94
+
95
+ exponential = _BASE_BACKOFF * 2**attempt
96
+ jitter = exponential * 0.25 * random.random()
97
+ return min(exponential + jitter, _MAX_BACKOFF)
98
+
99
+
100
+ class _BaseTransport:
101
+ def __init__(self, api_key: str | None, max_retries: int) -> None:
102
+ self._api_key = api_key
103
+ self._max_retries = max_retries
104
+
105
+ def _headers(self, request: Request) -> dict[str, str]:
106
+ headers = {"accept": "application/json"}
107
+ if request.auth:
108
+ if not self._api_key:
109
+ raise APIConnectionError(
110
+ "This endpoint requires an API key. Set api_key on the client."
111
+ )
112
+ headers["x-api-key"] = self._api_key
113
+ return headers
114
+
115
+
116
+ class SyncTransport(_BaseTransport):
117
+ def __init__(
118
+ self,
119
+ *,
120
+ api_key: str | None,
121
+ base_url: str,
122
+ max_retries: int,
123
+ timeout: float,
124
+ transport: httpx.BaseTransport | None = None,
125
+ ) -> None:
126
+ super().__init__(api_key, max_retries)
127
+ self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
128
+
129
+ def request(self, request: Request) -> Any:
130
+ attempt = 0
131
+ while True:
132
+ try:
133
+ return self._attempt(request)
134
+ except Exception as error:
135
+ retryable = attempt < self._max_retries and _is_retryable(error)
136
+ if not retryable:
137
+ raise
138
+
139
+ retry_after = error.retry_after if isinstance(error, PkmnPricesError) else None
140
+ time.sleep(_backoff(attempt, retry_after))
141
+ attempt += 1
142
+
143
+ def _attempt(self, request: Request) -> Any:
144
+ try:
145
+ response = self._client.request(
146
+ request.method, request.path, params=request.query, headers=self._headers(request)
147
+ )
148
+ except httpx.TimeoutException as error:
149
+ raise APIConnectionError(f"Request to {request.path} timed out", error) from error
150
+ except httpx.HTTPError as error:
151
+ raise APIConnectionError(f"Request to {request.path} failed", error) from error
152
+
153
+ return _handle(response, request.path)
154
+
155
+ def close(self) -> None:
156
+ self._client.close()
157
+
158
+
159
+ class AsyncTransport(_BaseTransport):
160
+ def __init__(
161
+ self,
162
+ *,
163
+ api_key: str | None,
164
+ base_url: str,
165
+ max_retries: int,
166
+ timeout: float,
167
+ transport: httpx.AsyncBaseTransport | None = None,
168
+ ) -> None:
169
+ super().__init__(api_key, max_retries)
170
+ self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport)
171
+
172
+ async def request(self, request: Request) -> Any:
173
+ attempt = 0
174
+ while True:
175
+ try:
176
+ return await self._attempt(request)
177
+ except Exception as error:
178
+ retryable = attempt < self._max_retries and _is_retryable(error)
179
+ if not retryable:
180
+ raise
181
+
182
+ retry_after = error.retry_after if isinstance(error, PkmnPricesError) else None
183
+ await asyncio.sleep(_backoff(attempt, retry_after))
184
+ attempt += 1
185
+
186
+ async def _attempt(self, request: Request) -> Any:
187
+ try:
188
+ response = await self._client.request(
189
+ request.method, request.path, params=request.query, headers=self._headers(request)
190
+ )
191
+ except httpx.TimeoutException as error:
192
+ raise APIConnectionError(f"Request to {request.path} timed out", error) from error
193
+ except httpx.HTTPError as error:
194
+ raise APIConnectionError(f"Request to {request.path} failed", error) from error
195
+
196
+ return _handle(response, request.path)
197
+
198
+ async def close(self) -> None:
199
+ await self._client.aclose()