T017: ledger client with typed errors

This commit is contained in:
m2 (AI Agent) 2026-07-02 02:37:19 +02:00
parent c607bc04b7
commit 75def66e7c

View 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"]