coincircuit 0.2.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.
- coincircuit-0.2.0/.gitignore +6 -0
- coincircuit-0.2.0/PKG-INFO +23 -0
- coincircuit-0.2.0/README.md +91 -0
- coincircuit-0.2.0/coincircuit/__init__.py +168 -0
- coincircuit-0.2.0/coincircuit/client.py +333 -0
- coincircuit-0.2.0/coincircuit/errors.py +47 -0
- coincircuit-0.2.0/coincircuit/pagination.py +100 -0
- coincircuit-0.2.0/coincircuit/resources/__init__.py +47 -0
- coincircuit-0.2.0/coincircuit/resources/balance.py +46 -0
- coincircuit-0.2.0/coincircuit/resources/bank_accounts.py +63 -0
- coincircuit-0.2.0/coincircuit/resources/blockchain.py +64 -0
- coincircuit-0.2.0/coincircuit/resources/crypto_addresses.py +63 -0
- coincircuit-0.2.0/coincircuit/resources/customers.py +142 -0
- coincircuit-0.2.0/coincircuit/resources/invoices.py +217 -0
- coincircuit-0.2.0/coincircuit/resources/payment_pages.py +57 -0
- coincircuit-0.2.0/coincircuit/resources/payments.py +364 -0
- coincircuit-0.2.0/coincircuit/resources/payouts.py +173 -0
- coincircuit-0.2.0/coincircuit/resources/rates.py +60 -0
- coincircuit-0.2.0/coincircuit/resources/refunds.py +260 -0
- coincircuit-0.2.0/coincircuit/resources/settlements.py +118 -0
- coincircuit-0.2.0/coincircuit/resources/transactions.py +111 -0
- coincircuit-0.2.0/coincircuit/resources/x402.py +137 -0
- coincircuit-0.2.0/coincircuit/types.py +905 -0
- coincircuit-0.2.0/pyproject.toml +37 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coincircuit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for the CoinCircuit API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: blockchain,coincircuit,crypto,payments,x402
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: httpx>=0.24.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# coincircuit
|
|
2
|
+
|
|
3
|
+
Official CoinCircuit Python SDK. Supports both synchronous and asynchronous usage.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install coincircuit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage (Sync)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from coincircuit import CoinCircuit
|
|
15
|
+
|
|
16
|
+
cc = CoinCircuit(api_key="api_live_...")
|
|
17
|
+
|
|
18
|
+
# Create a payment session
|
|
19
|
+
session = cc.payments.create(
|
|
20
|
+
amount="10.00",
|
|
21
|
+
currency="USD",
|
|
22
|
+
asset="USDC",
|
|
23
|
+
chain="base",
|
|
24
|
+
customer={"email": "buyer@example.com"},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# List transactions
|
|
28
|
+
result = cc.transactions.list(page=1, size=20)
|
|
29
|
+
for tx in result.data:
|
|
30
|
+
print(tx)
|
|
31
|
+
|
|
32
|
+
# Auto-paginate
|
|
33
|
+
for tx in cc.transactions.list_auto_paginate():
|
|
34
|
+
print(tx)
|
|
35
|
+
|
|
36
|
+
# Settle x402 gasless payment
|
|
37
|
+
result = cc.x402.settle(
|
|
38
|
+
scheme="eip3009",
|
|
39
|
+
chain="base",
|
|
40
|
+
asset="USDC",
|
|
41
|
+
payload={
|
|
42
|
+
"from": "0xSender...",
|
|
43
|
+
"to": "0xDeposit...",
|
|
44
|
+
"value": "1000000",
|
|
45
|
+
"validAfter": "0",
|
|
46
|
+
"validBefore": "1774055094",
|
|
47
|
+
"nonce": "0xrandom32bytes...",
|
|
48
|
+
"signature": "0xsigned...",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage (Async)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from coincircuit import AsyncCoinCircuit
|
|
57
|
+
|
|
58
|
+
async with AsyncCoinCircuit(api_key="api_live_...") as cc:
|
|
59
|
+
session = await cc.payments.create(
|
|
60
|
+
amount="10.00",
|
|
61
|
+
currency="USD",
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Resources
|
|
66
|
+
|
|
67
|
+
| Resource | Methods |
|
|
68
|
+
|----------|---------|
|
|
69
|
+
| `cc.payments` | create, list, get, generate_deposit_address, estimate |
|
|
70
|
+
| `cc.x402` | settle, verify |
|
|
71
|
+
| `cc.invoices` | create, list, get |
|
|
72
|
+
| `cc.transactions` | list, get |
|
|
73
|
+
| `cc.payouts` | create, list, get, estimate_fees |
|
|
74
|
+
| `cc.balance` | get |
|
|
75
|
+
| `cc.blockchain` | get_supported_assets, get_confirmations, get_gas_fees |
|
|
76
|
+
| `cc.customers` | create, list, get |
|
|
77
|
+
| `cc.refunds` | create_for_session, create_for_invoice, list, get, estimate |
|
|
78
|
+
| `cc.settlements` | list, get |
|
|
79
|
+
| `cc.rates` | convert, list |
|
|
80
|
+
| `cc.payment_pages` | create, list, update, delete, sessions |
|
|
81
|
+
| `cc.bank_accounts` | create, list, get, update, delete, banks |
|
|
82
|
+
| `cc.crypto_addresses` | create, list, get, update, delete, validate |
|
|
83
|
+
|
|
84
|
+
## Links
|
|
85
|
+
|
|
86
|
+
- [API Reference](https://coincircuit.io/api-reference)
|
|
87
|
+
- [Dashboard](https://dashboard.coincircuit.io)
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""CoinCircuit Python SDK.
|
|
2
|
+
|
|
3
|
+
Usage (synchronous)::
|
|
4
|
+
|
|
5
|
+
from coincircuit import CoinCircuit
|
|
6
|
+
|
|
7
|
+
cc = CoinCircuit(api_key="sk_live_...")
|
|
8
|
+
session = cc.payments.create(
|
|
9
|
+
title="Order #123",
|
|
10
|
+
description="Widget purchase",
|
|
11
|
+
amount="10.00",
|
|
12
|
+
currency="USD",
|
|
13
|
+
customer={"email": "buyer@example.com"},
|
|
14
|
+
asset="USDC",
|
|
15
|
+
chain="base",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Usage (asynchronous)::
|
|
19
|
+
|
|
20
|
+
from coincircuit import AsyncCoinCircuit
|
|
21
|
+
|
|
22
|
+
async_cc = AsyncCoinCircuit(api_key="sk_live_...")
|
|
23
|
+
session = await async_cc.payments.create(...)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import AsyncHttpClient, HttpClient
|
|
27
|
+
from .errors import CoinCircuitApiError, CoinCircuitAuthError, CoinCircuitError
|
|
28
|
+
from .pagination import PaginatedResponse
|
|
29
|
+
from .resources.balance import AsyncBalanceResource, BalanceResource
|
|
30
|
+
from .resources.blockchain import AsyncBlockchainResource, BlockchainResource
|
|
31
|
+
from .resources.customers import AsyncCustomersResource, CustomersResource
|
|
32
|
+
from .resources.invoices import AsyncInvoicesResource, InvoicesResource
|
|
33
|
+
from .resources.payments import AsyncPaymentsResource, PaymentsResource
|
|
34
|
+
from .resources.payouts import AsyncPayoutsResource, PayoutsResource
|
|
35
|
+
from .resources.rates import AsyncRatesResource, RatesResource
|
|
36
|
+
from .resources.refunds import AsyncRefundsResource, RefundsResource
|
|
37
|
+
from .resources.settlements import AsyncSettlementsResource, SettlementsResource
|
|
38
|
+
from .resources.transactions import AsyncTransactionsResource, TransactionsResource
|
|
39
|
+
from .resources.x402 import AsyncX402Resource, X402Resource
|
|
40
|
+
from .resources.payment_pages import AsyncPaymentPagesResource, PaymentPagesResource
|
|
41
|
+
from .resources.bank_accounts import AsyncBankAccountsResource, BankAccountsResource
|
|
42
|
+
from .resources.crypto_addresses import AsyncCryptoAddressesResource, CryptoAddressesResource
|
|
43
|
+
|
|
44
|
+
__version__ = "1.0.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Main clients
|
|
48
|
+
"CoinCircuit",
|
|
49
|
+
"AsyncCoinCircuit",
|
|
50
|
+
# Errors
|
|
51
|
+
"CoinCircuitError",
|
|
52
|
+
"CoinCircuitApiError",
|
|
53
|
+
"CoinCircuitAuthError",
|
|
54
|
+
# Pagination
|
|
55
|
+
"PaginatedResponse",
|
|
56
|
+
# Version
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CoinCircuit:
|
|
62
|
+
"""Synchronous CoinCircuit API client.
|
|
63
|
+
|
|
64
|
+
Provides access to all CoinCircuit API resources through
|
|
65
|
+
namespace attributes (e.g. ``cc.payments``, ``cc.invoices``).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api_key: Your CoinCircuit API key (e.g. "sk_live_...").
|
|
69
|
+
base_url: API base URL. Defaults to https://api.coincircuit.io.
|
|
70
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
71
|
+
max_retries: Max retry attempts for transient errors. Defaults to 2.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
api_key: str,
|
|
77
|
+
*,
|
|
78
|
+
base_url: str = "https://api.coincircuit.io",
|
|
79
|
+
timeout: float = 30.0,
|
|
80
|
+
max_retries: int = 2,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._client = HttpClient(
|
|
83
|
+
api_key=api_key,
|
|
84
|
+
base_url=base_url,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
max_retries=max_retries,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Resource namespaces
|
|
90
|
+
self.payments = PaymentsResource(self._client)
|
|
91
|
+
self.x402 = X402Resource(self._client)
|
|
92
|
+
self.invoices = InvoicesResource(self._client)
|
|
93
|
+
self.transactions = TransactionsResource(self._client)
|
|
94
|
+
self.payouts = PayoutsResource(self._client)
|
|
95
|
+
self.balance = BalanceResource(self._client)
|
|
96
|
+
self.blockchain = BlockchainResource(self._client)
|
|
97
|
+
self.customers = CustomersResource(self._client)
|
|
98
|
+
self.refunds = RefundsResource(self._client)
|
|
99
|
+
self.settlements = SettlementsResource(self._client)
|
|
100
|
+
self.rates = RatesResource(self._client)
|
|
101
|
+
self.payment_pages = PaymentPagesResource(self._client)
|
|
102
|
+
self.bank_accounts = BankAccountsResource(self._client)
|
|
103
|
+
self.crypto_addresses = CryptoAddressesResource(self._client)
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close the underlying HTTP client and release resources."""
|
|
107
|
+
self._client.close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "CoinCircuit":
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *args: object) -> None:
|
|
113
|
+
self.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AsyncCoinCircuit:
|
|
117
|
+
"""Asynchronous CoinCircuit API client.
|
|
118
|
+
|
|
119
|
+
Provides the same resource namespaces as ``CoinCircuit`` but all
|
|
120
|
+
methods are coroutines.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
api_key: Your CoinCircuit API key (e.g. "sk_live_...").
|
|
124
|
+
base_url: API base URL. Defaults to https://api.coincircuit.io.
|
|
125
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
126
|
+
max_retries: Max retry attempts for transient errors. Defaults to 2.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
api_key: str,
|
|
132
|
+
*,
|
|
133
|
+
base_url: str = "https://api.coincircuit.io",
|
|
134
|
+
timeout: float = 30.0,
|
|
135
|
+
max_retries: int = 2,
|
|
136
|
+
) -> None:
|
|
137
|
+
self._client = AsyncHttpClient(
|
|
138
|
+
api_key=api_key,
|
|
139
|
+
base_url=base_url,
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
max_retries=max_retries,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Resource namespaces
|
|
145
|
+
self.payments = AsyncPaymentsResource(self._client)
|
|
146
|
+
self.x402 = AsyncX402Resource(self._client)
|
|
147
|
+
self.invoices = AsyncInvoicesResource(self._client)
|
|
148
|
+
self.transactions = AsyncTransactionsResource(self._client)
|
|
149
|
+
self.payouts = AsyncPayoutsResource(self._client)
|
|
150
|
+
self.balance = AsyncBalanceResource(self._client)
|
|
151
|
+
self.blockchain = AsyncBlockchainResource(self._client)
|
|
152
|
+
self.customers = AsyncCustomersResource(self._client)
|
|
153
|
+
self.refunds = AsyncRefundsResource(self._client)
|
|
154
|
+
self.settlements = AsyncSettlementsResource(self._client)
|
|
155
|
+
self.rates = AsyncRatesResource(self._client)
|
|
156
|
+
self.payment_pages = AsyncPaymentPagesResource(self._client)
|
|
157
|
+
self.bank_accounts = AsyncBankAccountsResource(self._client)
|
|
158
|
+
self.crypto_addresses = AsyncCryptoAddressesResource(self._client)
|
|
159
|
+
|
|
160
|
+
async def close(self) -> None:
|
|
161
|
+
"""Close the underlying async HTTP client and release resources."""
|
|
162
|
+
await self._client.close()
|
|
163
|
+
|
|
164
|
+
async def __aenter__(self) -> "AsyncCoinCircuit":
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, *args: object) -> None:
|
|
168
|
+
await self.close()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Low-level HTTP client with retries, auth, and envelope unwrapping."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import CoinCircuitApiError, CoinCircuitAuthError, CoinCircuitError
|
|
9
|
+
from .pagination import PaginatedResponse
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://api.coincircuit.io"
|
|
12
|
+
DEFAULT_TIMEOUT = 30.0
|
|
13
|
+
MAX_RETRIES = 2
|
|
14
|
+
RETRYABLE_STATUS_CODES = (429, 500, 502, 503, 504)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_headers(api_key: str) -> Dict[str, str]:
|
|
18
|
+
return {
|
|
19
|
+
"x-api-key": api_key,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"Accept": "application/json",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_response(response: httpx.Response) -> Any:
|
|
26
|
+
"""Parse the response, raise on errors, unwrap the envelope."""
|
|
27
|
+
status = response.status_code
|
|
28
|
+
|
|
29
|
+
# Try to decode JSON body
|
|
30
|
+
try:
|
|
31
|
+
body = response.json()
|
|
32
|
+
except Exception:
|
|
33
|
+
body = {}
|
|
34
|
+
|
|
35
|
+
# Auth errors (401, 403)
|
|
36
|
+
if status in (401, 403):
|
|
37
|
+
message = body.get("message", "Authentication failed")
|
|
38
|
+
if isinstance(message, list):
|
|
39
|
+
message = "; ".join(message)
|
|
40
|
+
raise CoinCircuitAuthError(
|
|
41
|
+
message=message,
|
|
42
|
+
status_code=status,
|
|
43
|
+
body=body,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Other error status codes
|
|
47
|
+
if status >= 400:
|
|
48
|
+
message = body.get("message", f"API request failed with status {status}")
|
|
49
|
+
if isinstance(message, list):
|
|
50
|
+
message = "; ".join(message)
|
|
51
|
+
raise CoinCircuitApiError(
|
|
52
|
+
message=message,
|
|
53
|
+
status_code=status,
|
|
54
|
+
body=body,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Envelope unwrapping: {"success": bool, "message": str, "data": T}
|
|
58
|
+
# If the body matches the envelope format, return just "data".
|
|
59
|
+
if isinstance(body, dict) and "success" in body:
|
|
60
|
+
if not body.get("success", True):
|
|
61
|
+
raise CoinCircuitApiError(
|
|
62
|
+
message=body.get("message", "Request was not successful"),
|
|
63
|
+
status_code=status,
|
|
64
|
+
body=body,
|
|
65
|
+
)
|
|
66
|
+
# Return data if present, otherwise the full body
|
|
67
|
+
if "data" in body:
|
|
68
|
+
return body["data"]
|
|
69
|
+
return body
|
|
70
|
+
|
|
71
|
+
return body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_paginated_response(response: httpx.Response) -> PaginatedResponse:
|
|
75
|
+
"""Parse a paginated response, returning a PaginatedResponse wrapper."""
|
|
76
|
+
status = response.status_code
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
body = response.json()
|
|
80
|
+
except Exception:
|
|
81
|
+
body = {}
|
|
82
|
+
|
|
83
|
+
if status in (401, 403):
|
|
84
|
+
message = body.get("message", "Authentication failed")
|
|
85
|
+
if isinstance(message, list):
|
|
86
|
+
message = "; ".join(message)
|
|
87
|
+
raise CoinCircuitAuthError(
|
|
88
|
+
message=message,
|
|
89
|
+
status_code=status,
|
|
90
|
+
body=body,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if status >= 400:
|
|
94
|
+
message = body.get("message", f"API request failed with status {status}")
|
|
95
|
+
if isinstance(message, list):
|
|
96
|
+
message = "; ".join(message)
|
|
97
|
+
raise CoinCircuitApiError(
|
|
98
|
+
message=message,
|
|
99
|
+
status_code=status,
|
|
100
|
+
body=body,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if isinstance(body, dict) and "success" in body:
|
|
104
|
+
if not body.get("success", True):
|
|
105
|
+
raise CoinCircuitApiError(
|
|
106
|
+
message=body.get("message", "Request was not successful"),
|
|
107
|
+
status_code=status,
|
|
108
|
+
body=body,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
data = body.get("data", [])
|
|
112
|
+
meta = body.get("meta", {"page": 1, "size": len(data), "total": len(data), "totalPages": 1})
|
|
113
|
+
return PaginatedResponse(data=data, meta=meta)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _backoff_delay(attempt: int) -> float:
|
|
117
|
+
"""Exponential backoff: 0.5s, 1.0s, 2.0s, etc."""
|
|
118
|
+
return 0.5 * (2 ** attempt)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class HttpClient:
|
|
122
|
+
"""Synchronous HTTP client for the CoinCircuit API.
|
|
123
|
+
|
|
124
|
+
Handles authentication, retries with exponential backoff, and
|
|
125
|
+
response envelope unwrapping.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
api_key: str,
|
|
131
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
132
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
133
|
+
max_retries: int = MAX_RETRIES,
|
|
134
|
+
) -> None:
|
|
135
|
+
if not api_key:
|
|
136
|
+
raise CoinCircuitError("api_key is required")
|
|
137
|
+
|
|
138
|
+
self.api_key = api_key
|
|
139
|
+
self.base_url = base_url.rstrip("/")
|
|
140
|
+
self.max_retries = max_retries
|
|
141
|
+
self._client = httpx.Client(
|
|
142
|
+
base_url=self.base_url,
|
|
143
|
+
headers=_build_headers(api_key),
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def request(
|
|
148
|
+
self,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
params: Optional[Dict[str, Any]] = None,
|
|
152
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
153
|
+
) -> Any:
|
|
154
|
+
"""Make a request, retry on transient errors, unwrap envelope."""
|
|
155
|
+
# Strip None values from params
|
|
156
|
+
if params:
|
|
157
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
158
|
+
|
|
159
|
+
last_error: Optional[Exception] = None
|
|
160
|
+
for attempt in range(self.max_retries + 1):
|
|
161
|
+
try:
|
|
162
|
+
response = self._client.request(
|
|
163
|
+
method=method,
|
|
164
|
+
url=path,
|
|
165
|
+
params=params,
|
|
166
|
+
json=json_body,
|
|
167
|
+
)
|
|
168
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
169
|
+
time.sleep(_backoff_delay(attempt))
|
|
170
|
+
continue
|
|
171
|
+
return _parse_response(response)
|
|
172
|
+
except (CoinCircuitAuthError, CoinCircuitApiError):
|
|
173
|
+
raise
|
|
174
|
+
except httpx.HTTPError as exc:
|
|
175
|
+
last_error = exc
|
|
176
|
+
if attempt < self.max_retries:
|
|
177
|
+
time.sleep(_backoff_delay(attempt))
|
|
178
|
+
continue
|
|
179
|
+
raise CoinCircuitError(f"HTTP request failed: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
# Should not reach here, but just in case
|
|
182
|
+
raise CoinCircuitError(f"Request failed after {self.max_retries + 1} attempts")
|
|
183
|
+
|
|
184
|
+
def request_paginated(
|
|
185
|
+
self,
|
|
186
|
+
method: str,
|
|
187
|
+
path: str,
|
|
188
|
+
params: Optional[Dict[str, Any]] = None,
|
|
189
|
+
) -> PaginatedResponse:
|
|
190
|
+
"""Make a request and return a PaginatedResponse."""
|
|
191
|
+
if params:
|
|
192
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
193
|
+
|
|
194
|
+
last_error: Optional[Exception] = None
|
|
195
|
+
for attempt in range(self.max_retries + 1):
|
|
196
|
+
try:
|
|
197
|
+
response = self._client.request(
|
|
198
|
+
method=method,
|
|
199
|
+
url=path,
|
|
200
|
+
params=params,
|
|
201
|
+
)
|
|
202
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
203
|
+
time.sleep(_backoff_delay(attempt))
|
|
204
|
+
continue
|
|
205
|
+
return _parse_paginated_response(response)
|
|
206
|
+
except (CoinCircuitAuthError, CoinCircuitApiError):
|
|
207
|
+
raise
|
|
208
|
+
except httpx.HTTPError as exc:
|
|
209
|
+
last_error = exc
|
|
210
|
+
if attempt < self.max_retries:
|
|
211
|
+
time.sleep(_backoff_delay(attempt))
|
|
212
|
+
continue
|
|
213
|
+
raise CoinCircuitError(f"HTTP request failed: {exc}") from exc
|
|
214
|
+
|
|
215
|
+
raise CoinCircuitError(f"Request failed after {self.max_retries + 1} attempts")
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
"""Close the underlying HTTP client."""
|
|
219
|
+
self._client.close()
|
|
220
|
+
|
|
221
|
+
def __enter__(self) -> "HttpClient":
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
def __exit__(self, *args: Any) -> None:
|
|
225
|
+
self.close()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AsyncHttpClient:
|
|
229
|
+
"""Asynchronous HTTP client for the CoinCircuit API.
|
|
230
|
+
|
|
231
|
+
Same behaviour as ``HttpClient`` but uses ``httpx.AsyncClient``.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
api_key: str,
|
|
237
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
238
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
239
|
+
max_retries: int = MAX_RETRIES,
|
|
240
|
+
) -> None:
|
|
241
|
+
if not api_key:
|
|
242
|
+
raise CoinCircuitError("api_key is required")
|
|
243
|
+
|
|
244
|
+
self.api_key = api_key
|
|
245
|
+
self.base_url = base_url.rstrip("/")
|
|
246
|
+
self.max_retries = max_retries
|
|
247
|
+
self._client = httpx.AsyncClient(
|
|
248
|
+
base_url=self.base_url,
|
|
249
|
+
headers=_build_headers(api_key),
|
|
250
|
+
timeout=timeout,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
path: str,
|
|
257
|
+
params: Optional[Dict[str, Any]] = None,
|
|
258
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
259
|
+
) -> Any:
|
|
260
|
+
"""Make an async request, retry on transient errors, unwrap envelope."""
|
|
261
|
+
import asyncio
|
|
262
|
+
|
|
263
|
+
if params:
|
|
264
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
265
|
+
|
|
266
|
+
last_error: Optional[Exception] = None
|
|
267
|
+
for attempt in range(self.max_retries + 1):
|
|
268
|
+
try:
|
|
269
|
+
response = await self._client.request(
|
|
270
|
+
method=method,
|
|
271
|
+
url=path,
|
|
272
|
+
params=params,
|
|
273
|
+
json=json_body,
|
|
274
|
+
)
|
|
275
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
276
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
277
|
+
continue
|
|
278
|
+
return _parse_response(response)
|
|
279
|
+
except (CoinCircuitAuthError, CoinCircuitApiError):
|
|
280
|
+
raise
|
|
281
|
+
except httpx.HTTPError as exc:
|
|
282
|
+
last_error = exc
|
|
283
|
+
if attempt < self.max_retries:
|
|
284
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
285
|
+
continue
|
|
286
|
+
raise CoinCircuitError(f"HTTP request failed: {exc}") from exc
|
|
287
|
+
|
|
288
|
+
raise CoinCircuitError(f"Request failed after {self.max_retries + 1} attempts")
|
|
289
|
+
|
|
290
|
+
async def request_paginated(
|
|
291
|
+
self,
|
|
292
|
+
method: str,
|
|
293
|
+
path: str,
|
|
294
|
+
params: Optional[Dict[str, Any]] = None,
|
|
295
|
+
) -> PaginatedResponse:
|
|
296
|
+
"""Make an async request and return a PaginatedResponse."""
|
|
297
|
+
import asyncio
|
|
298
|
+
|
|
299
|
+
if params:
|
|
300
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
301
|
+
|
|
302
|
+
last_error: Optional[Exception] = None
|
|
303
|
+
for attempt in range(self.max_retries + 1):
|
|
304
|
+
try:
|
|
305
|
+
response = await self._client.request(
|
|
306
|
+
method=method,
|
|
307
|
+
url=path,
|
|
308
|
+
params=params,
|
|
309
|
+
)
|
|
310
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
|
|
311
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
312
|
+
continue
|
|
313
|
+
return _parse_paginated_response(response)
|
|
314
|
+
except (CoinCircuitAuthError, CoinCircuitApiError):
|
|
315
|
+
raise
|
|
316
|
+
except httpx.HTTPError as exc:
|
|
317
|
+
last_error = exc
|
|
318
|
+
if attempt < self.max_retries:
|
|
319
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
320
|
+
continue
|
|
321
|
+
raise CoinCircuitError(f"HTTP request failed: {exc}") from exc
|
|
322
|
+
|
|
323
|
+
raise CoinCircuitError(f"Request failed after {self.max_retries + 1} attempts")
|
|
324
|
+
|
|
325
|
+
async def close(self) -> None:
|
|
326
|
+
"""Close the underlying async HTTP client."""
|
|
327
|
+
await self._client.aclose()
|
|
328
|
+
|
|
329
|
+
async def __aenter__(self) -> "AsyncHttpClient":
|
|
330
|
+
return self
|
|
331
|
+
|
|
332
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
333
|
+
await self.close()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""CoinCircuit SDK error classes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CoinCircuitError(Exception):
|
|
7
|
+
"""Base error for all CoinCircuit SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
self.message = message
|
|
11
|
+
super().__init__(self.message)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CoinCircuitApiError(CoinCircuitError):
|
|
15
|
+
"""Raised when the CoinCircuit API returns a non-success response."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
status_code: int,
|
|
21
|
+
body: Optional[Dict[str, Any]] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body or {}
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return f"CoinCircuitApiError({self.status_code}): {self.message}"
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return (
|
|
32
|
+
f"CoinCircuitApiError(message={self.message!r}, "
|
|
33
|
+
f"status_code={self.status_code!r})"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CoinCircuitAuthError(CoinCircuitApiError):
|
|
38
|
+
"""Raised when the API returns 401 Unauthorized or 403 Forbidden."""
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return f"CoinCircuitAuthError({self.status_code}): {self.message}"
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return (
|
|
45
|
+
f"CoinCircuitAuthError(message={self.message!r}, "
|
|
46
|
+
f"status_code={self.status_code!r})"
|
|
47
|
+
)
|