T017: ledger client with typed errors
This commit is contained in:
parent
c607bc04b7
commit
75def66e7c
1 changed files with 96 additions and 0 deletions
96
cli/src/m2_market/ledger.py
Normal file
96
cli/src/m2_market/ledger.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""HTTP client for the m2-ledger service (contracts/ledger-api.md)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class LedgerError(Exception):
|
||||
"""Any ledger error not covered by a more specific exception."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None, body: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.body = body or {}
|
||||
|
||||
|
||||
class InsufficientFunds(LedgerError):
|
||||
"""Raised on 402 — insufficient balance, nothing written."""
|
||||
|
||||
|
||||
class DuplicateRef(LedgerError):
|
||||
"""Raised on 409 — same ref already settled/refunded (idempotency guard)."""
|
||||
|
||||
|
||||
class LedgerClient:
|
||||
def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_TIMEOUT):
|
||||
self._client = httpx.Client(
|
||||
base_url=base_url,
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "LedgerClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self.close()
|
||||
|
||||
def _raise_for_error(self, response: httpx.Response) -> None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
message = body.get("error", response.text)
|
||||
if response.status_code == 402:
|
||||
raise InsufficientFunds(message, response.status_code, body)
|
||||
if response.status_code == 409:
|
||||
raise DuplicateRef(message, response.status_code, body)
|
||||
raise LedgerError(message, response.status_code, body)
|
||||
|
||||
def balance(self, operator_id: str) -> dict:
|
||||
response = self._client.get(f"/balance/{operator_id}")
|
||||
if response.status_code != 200:
|
||||
self._raise_for_error(response)
|
||||
return response.json()
|
||||
|
||||
def install(
|
||||
self,
|
||||
buyer: str,
|
||||
seller: str,
|
||||
amount: int,
|
||||
ref: str,
|
||||
platform_pct: float | None = None,
|
||||
) -> dict:
|
||||
body = {"buyer": buyer, "seller": seller, "amount": amount, "ref": ref}
|
||||
if platform_pct is not None:
|
||||
body["platform_pct"] = platform_pct
|
||||
response = self._client.post("/tx/install", json=body)
|
||||
if response.status_code != 200:
|
||||
self._raise_for_error(response)
|
||||
return response.json()["grant"]
|
||||
|
||||
def refund(self, ref: str) -> list:
|
||||
response = self._client.post("/tx/refund", json={"ref": ref})
|
||||
if response.status_code != 200:
|
||||
self._raise_for_error(response)
|
||||
return response.json()["tx_ids"]
|
||||
|
||||
def transactions(self, **filters) -> list:
|
||||
response = self._client.get("/tx", params=filters)
|
||||
if response.status_code != 200:
|
||||
self._raise_for_error(response)
|
||||
return response.json()["transactions"]
|
||||
|
||||
def license(self, operator_id: str, listing_id: str) -> dict | None:
|
||||
response = self._client.get(f"/license/{operator_id}/{listing_id}")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
self._raise_for_error(response)
|
||||
return response.json()["grant"]
|
||||
Loading…
Reference in a new issue