decane-sdk 0.8.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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Decane
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,213 @@
1
+ Metadata-Version: 2.5
2
+ Name: decane-sdk
3
+ Version: 0.8.0
4
+ Summary: Official Python SDK for Decane — institutional liquidity rail for crypto settlement
5
+ Project-URL: Homepage, https://decane.io
6
+ Project-URL: Documentation, https://decane.io/docs
7
+ Project-URL: Repository, https://github.com/Decane-io/decane-python
8
+ Project-URL: Issues, https://github.com/Decane-io/decane-python/issues
9
+ Author-email: Decane <dev@decane.io>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: bitcoin,crypto,ethereum,payment-gateway,payments,stablecoins
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.25.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio; extra == 'dev'
28
+ Requires-Dist: respx; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # decane-sdk
33
+
34
+ Official Python SDK for Decane — institutional liquidity rail for crypto settlement.
35
+
36
+ > **Renamed from granthe.** The old package was `granthe` on PyPI. The API and webhook formats are unchanged, and the server still sends `Granthe-Signature` (and `X-Granthe-Signature`) for older integrations. Read `Decane-Signature` first and fall back to `Granthe-Signature` if you handle both.
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/decane-sdk)](https://pypi.org/project/decane-sdk/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/decane-sdk)](https://pypi.org/project/decane-sdk/)
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install decane-sdk
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ from decane import Decane
51
+
52
+ decane = Decane(api_key="gr_...", webhook_secret="gr_whsec_...")
53
+
54
+ # Create a payment invoice
55
+ invoice = decane.invoices.create(
56
+ amount=100.00,
57
+ crypto_currency="USDT",
58
+ network="tron",
59
+ description="Premium Plan",
60
+ customer_email="buyer@example.com",
61
+ idempotency_key="order-1042", # optional, safe to retry
62
+ )
63
+ print(f"Pay to: {invoice.wallet_address}")
64
+ print(f"Status: {invoice.status}")
65
+
66
+ # Request a payout
67
+ payout = decane.payouts.create(
68
+ amount=50.00,
69
+ currency="USDT",
70
+ network="tron",
71
+ recipient_address="TXyz...recipientAddress",
72
+ idempotency_key="payout-1042", # optional, safe to retry
73
+ )
74
+ print(f"Payout ID: {payout.id}")
75
+ ```
76
+
77
+ ## Async Support
78
+
79
+ ```python
80
+ from decane import AsyncFlow
81
+
82
+ async with AsyncFlow(api_key="gr_...") as flow:
83
+ invoice = await decane.invoices.create(
84
+ amount=100, crypto_currency="USDT", network="ethereum",
85
+ )
86
+ ```
87
+
88
+ ## Resources
89
+
90
+ ### Invoices
91
+
92
+ ```python
93
+ # Create
94
+ invoice = decane.invoices.create(amount=100, crypto_currency="USDT", network="tron")
95
+
96
+ # Get by ID
97
+ invoice = decane.invoices.get("inv_abc123")
98
+
99
+ # List with filters
100
+ result = decane.invoices.list(status="paid", page=1, limit=20)
101
+ for inv in result.items:
102
+ print(inv.id, inv.amount, inv.status)
103
+
104
+ # Cancel
105
+ decane.invoices.cancel("inv_abc123")
106
+ ```
107
+
108
+ ### Payouts
109
+
110
+ ```python
111
+ # Single payout
112
+ payout = decane.payouts.create(
113
+ amount=50, currency="USDT", network="tron",
114
+ recipient_address="TXyz...addr",
115
+ )
116
+
117
+ # Batch payout
118
+ payouts = decane.payouts.create_batch([
119
+ {"amount": 50, "currency": "USDT", "network": "tron", "recipient_address": "TXyz..."},
120
+ {"amount": 25, "currency": "USDT", "network": "tron", "recipient_address": "TAbc..."},
121
+ ])
122
+
123
+ # Check status
124
+ payout = decane.payouts.get("pout_abc123")
125
+ ```
126
+
127
+ ### Wallets
128
+
129
+ ```python
130
+ # Generate a new deposit wallet
131
+ wallet = decane.wallets.generate(network="ethereum", currency="USDT")
132
+ print(wallet.address)
133
+
134
+ # List wallets
135
+ result = decane.wallets.list(network="tron")
136
+
137
+ # Check balance
138
+ wallet = decane.wallets.balance("wal_abc123")
139
+ ```
140
+
141
+ ### Swaps
142
+
143
+ ```python
144
+ # Get a quote
145
+ quote = flow.swaps.quote("BTC", "USDT", amount=0.01)
146
+ print(f"Rate: {quote.rate}, You get: {quote.to_amount} USDT")
147
+
148
+ # Execute swap
149
+ swap = flow.swaps.create("BTC", "USDT", amount=0.01)
150
+ ```
151
+
152
+ ### Webhooks
153
+
154
+ ```python
155
+ from decane import Decane, InvalidSignatureError
156
+
157
+ decane = Decane(api_key="gr_...", webhook_secret="gr_whsec_...")
158
+
159
+ # In your webhook handler. Use the raw body, not re-serialized JSON.
160
+ def handle_webhook(request):
161
+ payload = request.body
162
+ header = request.headers.get("Decane-Signature") or request.headers["Granthe-Signature"] # t=<unix>,v1=<hex>
163
+
164
+ try:
165
+ event = decane.webhooks.construct_event(payload, header, tolerance=300)
166
+ except InvalidSignatureError:
167
+ return {"error": "Invalid signature"}, 400
168
+
169
+ # X-Decane-Delivery is the same on every retry, so use it to drop duplicates.
170
+ if event.type == "invoice.completed":
171
+ invoice_id = event.data["id"]
172
+ print(f"Invoice {invoice_id} completed!")
173
+
174
+ return {"status": "ok"}
175
+ ```
176
+
177
+ `decane.webhooks.verify_signature(payload, header)` returns a bool if you want to parse the body yourself. The old `webhooks.verify(payload, signature)` for `X-Decane-Signature` (or `X-Granthe-Signature`) still works but is deprecated, because it has no timestamp check.
178
+
179
+ ## Error Handling
180
+
181
+ ```python
182
+ from decane import Decane, DecaneError, AuthenticationError, RateLimitError
183
+
184
+ decane = Decane(api_key="gr_...")
185
+
186
+ try:
187
+ invoice = decane.invoices.create(amount=100, crypto_currency="USDT", network="tron")
188
+ except AuthenticationError:
189
+ print("Bad API key")
190
+ except RateLimitError as e:
191
+ print(f"Rate limited, retry after {e.retry_after}s")
192
+ except DecaneError as e:
193
+ print(f"Error: {e.message} (code={e.code}, status={e.status})")
194
+ ```
195
+
196
+ ## Configuration
197
+
198
+ ```python
199
+ decane = Decane(
200
+ api_key="gr_...",
201
+ webhook_secret="gr_whsec_...", # For webhook verification
202
+ base_url="https://api.decane.io/api/v1", # Default
203
+ timeout=30.0, # Request timeout in seconds
204
+ )
205
+ ```
206
+
207
+ ## Supported Networks
208
+
209
+ Bitcoin, Ethereum, Tron, BNB Chain, Solana, Ripple, Polygon, Avalanche, Dogecoin, Litecoin, TON, and more.
210
+
211
+ ## License
212
+
213
+ MIT
@@ -0,0 +1,182 @@
1
+ # decane-sdk
2
+
3
+ Official Python SDK for Decane — institutional liquidity rail for crypto settlement.
4
+
5
+ > **Renamed from granthe.** The old package was `granthe` on PyPI. The API and webhook formats are unchanged, and the server still sends `Granthe-Signature` (and `X-Granthe-Signature`) for older integrations. Read `Decane-Signature` first and fall back to `Granthe-Signature` if you handle both.
6
+
7
+ [![PyPI](https://img.shields.io/pypi/v/decane-sdk)](https://pypi.org/project/decane-sdk/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/decane-sdk)](https://pypi.org/project/decane-sdk/)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install decane-sdk
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```python
19
+ from decane import Decane
20
+
21
+ decane = Decane(api_key="gr_...", webhook_secret="gr_whsec_...")
22
+
23
+ # Create a payment invoice
24
+ invoice = decane.invoices.create(
25
+ amount=100.00,
26
+ crypto_currency="USDT",
27
+ network="tron",
28
+ description="Premium Plan",
29
+ customer_email="buyer@example.com",
30
+ idempotency_key="order-1042", # optional, safe to retry
31
+ )
32
+ print(f"Pay to: {invoice.wallet_address}")
33
+ print(f"Status: {invoice.status}")
34
+
35
+ # Request a payout
36
+ payout = decane.payouts.create(
37
+ amount=50.00,
38
+ currency="USDT",
39
+ network="tron",
40
+ recipient_address="TXyz...recipientAddress",
41
+ idempotency_key="payout-1042", # optional, safe to retry
42
+ )
43
+ print(f"Payout ID: {payout.id}")
44
+ ```
45
+
46
+ ## Async Support
47
+
48
+ ```python
49
+ from decane import AsyncFlow
50
+
51
+ async with AsyncFlow(api_key="gr_...") as flow:
52
+ invoice = await decane.invoices.create(
53
+ amount=100, crypto_currency="USDT", network="ethereum",
54
+ )
55
+ ```
56
+
57
+ ## Resources
58
+
59
+ ### Invoices
60
+
61
+ ```python
62
+ # Create
63
+ invoice = decane.invoices.create(amount=100, crypto_currency="USDT", network="tron")
64
+
65
+ # Get by ID
66
+ invoice = decane.invoices.get("inv_abc123")
67
+
68
+ # List with filters
69
+ result = decane.invoices.list(status="paid", page=1, limit=20)
70
+ for inv in result.items:
71
+ print(inv.id, inv.amount, inv.status)
72
+
73
+ # Cancel
74
+ decane.invoices.cancel("inv_abc123")
75
+ ```
76
+
77
+ ### Payouts
78
+
79
+ ```python
80
+ # Single payout
81
+ payout = decane.payouts.create(
82
+ amount=50, currency="USDT", network="tron",
83
+ recipient_address="TXyz...addr",
84
+ )
85
+
86
+ # Batch payout
87
+ payouts = decane.payouts.create_batch([
88
+ {"amount": 50, "currency": "USDT", "network": "tron", "recipient_address": "TXyz..."},
89
+ {"amount": 25, "currency": "USDT", "network": "tron", "recipient_address": "TAbc..."},
90
+ ])
91
+
92
+ # Check status
93
+ payout = decane.payouts.get("pout_abc123")
94
+ ```
95
+
96
+ ### Wallets
97
+
98
+ ```python
99
+ # Generate a new deposit wallet
100
+ wallet = decane.wallets.generate(network="ethereum", currency="USDT")
101
+ print(wallet.address)
102
+
103
+ # List wallets
104
+ result = decane.wallets.list(network="tron")
105
+
106
+ # Check balance
107
+ wallet = decane.wallets.balance("wal_abc123")
108
+ ```
109
+
110
+ ### Swaps
111
+
112
+ ```python
113
+ # Get a quote
114
+ quote = flow.swaps.quote("BTC", "USDT", amount=0.01)
115
+ print(f"Rate: {quote.rate}, You get: {quote.to_amount} USDT")
116
+
117
+ # Execute swap
118
+ swap = flow.swaps.create("BTC", "USDT", amount=0.01)
119
+ ```
120
+
121
+ ### Webhooks
122
+
123
+ ```python
124
+ from decane import Decane, InvalidSignatureError
125
+
126
+ decane = Decane(api_key="gr_...", webhook_secret="gr_whsec_...")
127
+
128
+ # In your webhook handler. Use the raw body, not re-serialized JSON.
129
+ def handle_webhook(request):
130
+ payload = request.body
131
+ header = request.headers.get("Decane-Signature") or request.headers["Granthe-Signature"] # t=<unix>,v1=<hex>
132
+
133
+ try:
134
+ event = decane.webhooks.construct_event(payload, header, tolerance=300)
135
+ except InvalidSignatureError:
136
+ return {"error": "Invalid signature"}, 400
137
+
138
+ # X-Decane-Delivery is the same on every retry, so use it to drop duplicates.
139
+ if event.type == "invoice.completed":
140
+ invoice_id = event.data["id"]
141
+ print(f"Invoice {invoice_id} completed!")
142
+
143
+ return {"status": "ok"}
144
+ ```
145
+
146
+ `decane.webhooks.verify_signature(payload, header)` returns a bool if you want to parse the body yourself. The old `webhooks.verify(payload, signature)` for `X-Decane-Signature` (or `X-Granthe-Signature`) still works but is deprecated, because it has no timestamp check.
147
+
148
+ ## Error Handling
149
+
150
+ ```python
151
+ from decane import Decane, DecaneError, AuthenticationError, RateLimitError
152
+
153
+ decane = Decane(api_key="gr_...")
154
+
155
+ try:
156
+ invoice = decane.invoices.create(amount=100, crypto_currency="USDT", network="tron")
157
+ except AuthenticationError:
158
+ print("Bad API key")
159
+ except RateLimitError as e:
160
+ print(f"Rate limited, retry after {e.retry_after}s")
161
+ except DecaneError as e:
162
+ print(f"Error: {e.message} (code={e.code}, status={e.status})")
163
+ ```
164
+
165
+ ## Configuration
166
+
167
+ ```python
168
+ decane = Decane(
169
+ api_key="gr_...",
170
+ webhook_secret="gr_whsec_...", # For webhook verification
171
+ base_url="https://api.decane.io/api/v1", # Default
172
+ timeout=30.0, # Request timeout in seconds
173
+ )
174
+ ```
175
+
176
+ ## Supported Networks
177
+
178
+ Bitcoin, Ethereum, Tron, BNB Chain, Solana, Ripple, Polygon, Avalanche, Dogecoin, Litecoin, TON, and more.
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,45 @@
1
+ """Decane — Official Python SDK for institutional liquidity rail for crypto settlement."""
2
+
3
+ from .client import Decane, AsyncDecane
4
+ from .errors import (
5
+ DecaneError,
6
+ AuthenticationError,
7
+ NotFoundError,
8
+ ValidationError,
9
+ RateLimitError,
10
+ ServerError,
11
+ InvalidSignatureError,
12
+ )
13
+ from .types import (
14
+ Invoice,
15
+ InvoiceStatus,
16
+ Payment,
17
+ Payout,
18
+ PayoutStatus,
19
+ Wallet,
20
+ SwapQuote,
21
+ Swap,
22
+ WebhookEvent,
23
+ )
24
+
25
+ __version__ = "0.8.0"
26
+ __all__ = [
27
+ "Decane",
28
+ "AsyncDecane",
29
+ "DecaneError",
30
+ "AuthenticationError",
31
+ "NotFoundError",
32
+ "ValidationError",
33
+ "RateLimitError",
34
+ "ServerError",
35
+ "InvalidSignatureError",
36
+ "Invoice",
37
+ "InvoiceStatus",
38
+ "Payment",
39
+ "Payout",
40
+ "PayoutStatus",
41
+ "Wallet",
42
+ "SwapQuote",
43
+ "Swap",
44
+ "WebhookEvent",
45
+ ]
File without changes
@@ -0,0 +1,195 @@
1
+ """HTTP transport layer for Decane SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from ..errors import (
12
+ AuthenticationError,
13
+ DecaneError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ ServerError,
17
+ ValidationError,
18
+ )
19
+
20
+ DEFAULT_BASE_URL = "https://api.decane.io/api/v1/"
21
+ DEFAULT_TIMEOUT = 30.0
22
+ MAX_RETRIES = 3
23
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
24
+
25
+
26
+ def _build_headers(api_key: str, api_secret: str | None = None) -> dict[str, str]:
27
+ headers = {
28
+ "X-API-Key": api_key,
29
+ "Content-Type": "application/json",
30
+ "User-Agent": "decane-python/0.8.0",
31
+ }
32
+ if api_secret:
33
+ headers["X-API-Secret"] = api_secret
34
+ return headers
35
+
36
+
37
+ def _with_idempotency(kwargs: dict[str, Any], key: str | None) -> dict[str, Any]:
38
+ """Return kwargs with an Idempotency-Key header (generated if not given)."""
39
+ headers = dict(kwargs.get("headers") or {})
40
+ headers["Idempotency-Key"] = key or str(uuid.uuid4())
41
+ return {**kwargs, "headers": headers}
42
+
43
+
44
+ def _raise_for_status(response: httpx.Response) -> None:
45
+ """Raise typed SDK errors from HTTP responses."""
46
+ if response.status_code < 400:
47
+ return
48
+
49
+ try:
50
+ body = response.json()
51
+ except Exception:
52
+ body = {"detail": response.text}
53
+
54
+ message = body.get("detail", body.get("message", body.get("error", str(body))))
55
+ code = body.get("code")
56
+
57
+ if response.status_code == 401:
58
+ raise AuthenticationError(message)
59
+ if response.status_code == 404:
60
+ raise NotFoundError(message)
61
+ if response.status_code == 422:
62
+ raise ValidationError(message, errors=body.get("errors"))
63
+ if response.status_code == 429:
64
+ retry_after = response.headers.get("Retry-After")
65
+ raise RateLimitError(message, retry_after=float(retry_after) if retry_after else None)
66
+ if response.status_code >= 500:
67
+ raise ServerError(message)
68
+
69
+ raise DecaneError(message, code=code, status=response.status_code)
70
+
71
+
72
+ class SyncHTTP:
73
+ """Synchronous HTTP client with retry logic."""
74
+
75
+ def __init__(self, api_key: str, base_url: str, timeout: float, api_secret: str | None = None):
76
+ self._client = httpx.Client(
77
+ base_url=base_url,
78
+ headers=_build_headers(api_key, api_secret),
79
+ timeout=timeout,
80
+ )
81
+
82
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
83
+ last_exc: Exception | None = None
84
+ for attempt in range(MAX_RETRIES):
85
+ try:
86
+ response = self._client.request(method, path, **kwargs)
87
+ if response.status_code in RETRY_STATUSES and attempt < MAX_RETRIES - 1:
88
+ wait = (2**attempt) * 0.5
89
+ if response.status_code == 429:
90
+ retry_after = response.headers.get("Retry-After")
91
+ if retry_after:
92
+ wait = float(retry_after)
93
+ time.sleep(wait)
94
+ continue
95
+ _raise_for_status(response)
96
+ if response.status_code == 204:
97
+ return None
98
+ return response.json()
99
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
100
+ last_exc = e
101
+ if attempt < MAX_RETRIES - 1:
102
+ time.sleep((2**attempt) * 0.5)
103
+ continue
104
+ raise DecaneError(f"Connection error: {e}", code="network_error") from e
105
+ if last_exc:
106
+ raise DecaneError(f"Max retries exceeded: {last_exc}", code="network_error") from last_exc
107
+ raise DecaneError("Unexpected error", code="unknown")
108
+
109
+ def get(self, path: str, **kwargs: Any) -> Any:
110
+ return self.request("GET", path, **kwargs)
111
+
112
+ def post(self, path: str, *, idempotency_key: str | None = None, **kwargs: Any) -> Any:
113
+ if idempotency_key is not None:
114
+ kwargs = _with_idempotency(kwargs, idempotency_key)
115
+ return self.request("POST", path, **kwargs)
116
+
117
+ def post_idempotent(self, path: str, *, idempotency_key: str | None = None, **kwargs: Any) -> Any:
118
+ """POST with an Idempotency-Key header (random UUID if none given)."""
119
+ return self.request("POST", path, **_with_idempotency(kwargs, idempotency_key))
120
+
121
+ def put(self, path: str, **kwargs: Any) -> Any:
122
+ return self.request("PUT", path, **kwargs)
123
+
124
+ def patch(self, path: str, **kwargs: Any) -> Any:
125
+ return self.request("PATCH", path, **kwargs)
126
+
127
+ def delete(self, path: str, **kwargs: Any) -> Any:
128
+ return self.request("DELETE", path, **kwargs)
129
+
130
+ def close(self) -> None:
131
+ self._client.close()
132
+
133
+
134
+ class AsyncHTTP:
135
+ """Async HTTP client with retry logic."""
136
+
137
+ def __init__(self, api_key: str, base_url: str, timeout: float, api_secret: str | None = None):
138
+ self._client = httpx.AsyncClient(
139
+ base_url=base_url,
140
+ headers=_build_headers(api_key, api_secret),
141
+ timeout=timeout,
142
+ )
143
+
144
+ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
145
+ import asyncio
146
+
147
+ last_exc: Exception | None = None
148
+ for attempt in range(MAX_RETRIES):
149
+ try:
150
+ response = await self._client.request(method, path, **kwargs)
151
+ if response.status_code in RETRY_STATUSES and attempt < MAX_RETRIES - 1:
152
+ wait = (2**attempt) * 0.5
153
+ if response.status_code == 429:
154
+ retry_after = response.headers.get("Retry-After")
155
+ if retry_after:
156
+ wait = float(retry_after)
157
+ await asyncio.sleep(wait)
158
+ continue
159
+ _raise_for_status(response)
160
+ if response.status_code == 204:
161
+ return None
162
+ return response.json()
163
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
164
+ last_exc = e
165
+ if attempt < MAX_RETRIES - 1:
166
+ await asyncio.sleep((2**attempt) * 0.5)
167
+ continue
168
+ raise DecaneError(f"Connection error: {e}", code="network_error") from e
169
+ if last_exc:
170
+ raise DecaneError(f"Max retries exceeded: {last_exc}", code="network_error") from last_exc
171
+ raise DecaneError("Unexpected error", code="unknown")
172
+
173
+ async def get(self, path: str, **kwargs: Any) -> Any:
174
+ return await self.request("GET", path, **kwargs)
175
+
176
+ async def post(self, path: str, *, idempotency_key: str | None = None, **kwargs: Any) -> Any:
177
+ if idempotency_key is not None:
178
+ kwargs = _with_idempotency(kwargs, idempotency_key)
179
+ return await self.request("POST", path, **kwargs)
180
+
181
+ async def post_idempotent(self, path: str, *, idempotency_key: str | None = None, **kwargs: Any) -> Any:
182
+ """POST with an Idempotency-Key header (random UUID if none given)."""
183
+ return await self.request("POST", path, **_with_idempotency(kwargs, idempotency_key))
184
+
185
+ async def put(self, path: str, **kwargs: Any) -> Any:
186
+ return await self.request("PUT", path, **kwargs)
187
+
188
+ async def patch(self, path: str, **kwargs: Any) -> Any:
189
+ return await self.request("PATCH", path, **kwargs)
190
+
191
+ async def delete(self, path: str, **kwargs: Any) -> Any:
192
+ return await self.request("DELETE", path, **kwargs)
193
+
194
+ async def close(self) -> None:
195
+ await self._client.aclose()