meterflow 0.1.0__py3-none-any.whl
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.
- meterflow/__init__.py +43 -0
- meterflow/_http.py +160 -0
- meterflow/_retry.py +38 -0
- meterflow/_version.py +3 -0
- meterflow/client.py +148 -0
- meterflow/errors.py +98 -0
- meterflow/py.typed +0 -0
- meterflow/resources/__init__.py +1 -0
- meterflow/resources/credits.py +77 -0
- meterflow/resources/entitlements.py +60 -0
- meterflow/resources/plans.py +49 -0
- meterflow/resources/subscriptions.py +89 -0
- meterflow/resources/usage.py +77 -0
- meterflow/types.py +214 -0
- meterflow/webhook.py +21 -0
- meterflow-0.1.0.dist-info/METADATA +560 -0
- meterflow-0.1.0.dist-info/RECORD +19 -0
- meterflow-0.1.0.dist-info/WHEEL +4 -0
- meterflow-0.1.0.dist-info/licenses/LICENSE +21 -0
meterflow/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
|
|
2
|
+
|
|
3
|
+
from meterflow import MeterFlow
|
|
4
|
+
|
|
5
|
+
client = MeterFlow(api_key="mf_live_...")
|
|
6
|
+
client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "metadata": {}})
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .client import AsyncMeterFlow, ClientOptions, MeterFlow
|
|
11
|
+
from .errors import (
|
|
12
|
+
AuthError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
InsufficientCreditsError,
|
|
15
|
+
MeterFlowError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PayloadTooLargeError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
ValidationFieldError,
|
|
22
|
+
)
|
|
23
|
+
from .resources.usage import MAX_BATCH_EVENTS
|
|
24
|
+
from .webhook import verify_webhook
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"MeterFlow",
|
|
29
|
+
"AsyncMeterFlow",
|
|
30
|
+
"ClientOptions",
|
|
31
|
+
"MeterFlowError",
|
|
32
|
+
"AuthError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"InsufficientCreditsError",
|
|
35
|
+
"ConflictError",
|
|
36
|
+
"PayloadTooLargeError",
|
|
37
|
+
"ValidationError",
|
|
38
|
+
"ValidationFieldError",
|
|
39
|
+
"RateLimitError",
|
|
40
|
+
"ServerError",
|
|
41
|
+
"MAX_BATCH_EVENTS",
|
|
42
|
+
"verify_webhook",
|
|
43
|
+
]
|
meterflow/_http.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The HTTP layer both clients share: URL/header building, error-body parsing, status → error mapping.
|
|
2
|
+
|
|
3
|
+
Only the *waiting* differs between sync and async, so the request loops live in ``client.py`` and
|
|
4
|
+
everything they call lives here, once. Mirrors ``sdks/node/src/utils/http.ts``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .errors import (
|
|
20
|
+
AuthError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
InsufficientCreditsError,
|
|
23
|
+
MeterFlowError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PayloadTooLargeError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ServerError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
ValidationFieldError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
USER_AGENT = f"meterflow-python/{__version__}"
|
|
33
|
+
|
|
34
|
+
QueryValue = str | int | float | bool | None
|
|
35
|
+
|
|
36
|
+
# Indirections so tests can make the retry loop instantaneous without touching the clock.
|
|
37
|
+
_sleep = time.sleep
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _async_sleep(seconds: float) -> None:
|
|
41
|
+
await asyncio.sleep(seconds)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RequestSpec:
|
|
46
|
+
"""One API call, described by a resource method and executed by a client."""
|
|
47
|
+
|
|
48
|
+
method: str
|
|
49
|
+
path: str
|
|
50
|
+
body: Any = None
|
|
51
|
+
query: dict[str, QueryValue] = field(default_factory=dict)
|
|
52
|
+
idempotency_key: str | None = None
|
|
53
|
+
timeout: float | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encode_path_segment(value: str) -> str:
|
|
57
|
+
"""Customer ids are caller-chosen strings (emails, ``user/42``…) — they must never split the path."""
|
|
58
|
+
return quote(value, safe="")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
|
|
62
|
+
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
63
|
+
if spec.body is not None:
|
|
64
|
+
headers["Content-Type"] = "application/json"
|
|
65
|
+
if spec.idempotency_key:
|
|
66
|
+
headers["Idempotency-Key"] = spec.idempotency_key
|
|
67
|
+
return headers
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_params(query: dict[str, QueryValue]) -> dict[str, str]:
|
|
71
|
+
"""Unset (``None``) filters are simply not sent; booleans go as ``true``/``false`` like the Node SDK."""
|
|
72
|
+
params: dict[str, str] = {}
|
|
73
|
+
for key, value in query.items():
|
|
74
|
+
if value is None:
|
|
75
|
+
continue
|
|
76
|
+
params[key] = ("true" if value else "false") if isinstance(value, bool) else str(value)
|
|
77
|
+
return params
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class ParsedError:
|
|
82
|
+
message: str
|
|
83
|
+
fields: list[ValidationFieldError]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_field_error(value: object) -> bool:
|
|
87
|
+
return isinstance(value, dict) and isinstance(value.get("field"), str) and isinstance(value.get("message"), str)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_error_body(response: httpx.Response) -> ParsedError:
|
|
91
|
+
"""The API answers every error as ``{"error": {"code", "message", "fields"?}}`` (``app/api/src/exceptions.py``).
|
|
92
|
+
|
|
93
|
+
``detail`` is FastAPI's default shape, kept as a fallback so an unexpected passthrough still
|
|
94
|
+
yields a readable message rather than a JSON blob.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
payload = response.json()
|
|
98
|
+
except (json.JSONDecodeError, ValueError):
|
|
99
|
+
return ParsedError(response.reason_phrase or f"HTTP {response.status_code}", [])
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
return ParsedError(json.dumps(payload), [])
|
|
102
|
+
error = payload.get("error")
|
|
103
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
104
|
+
raw_fields = error.get("fields")
|
|
105
|
+
fields: list[ValidationFieldError] = (
|
|
106
|
+
[{"field": item["field"], "message": item["message"]} for item in raw_fields if _is_field_error(item)]
|
|
107
|
+
if isinstance(raw_fields, list)
|
|
108
|
+
else []
|
|
109
|
+
)
|
|
110
|
+
# A 422's headline is always "Validation failed"; the field list is what the caller needs.
|
|
111
|
+
detail = "; ".join(f"{item['field']}: {item['message']}" for item in fields)
|
|
112
|
+
message = f"{error['message']}: {detail}" if detail else error["message"]
|
|
113
|
+
return ParsedError(message, fields)
|
|
114
|
+
detail_value = payload.get("detail")
|
|
115
|
+
if isinstance(detail_value, str):
|
|
116
|
+
return ParsedError(detail_value, [])
|
|
117
|
+
return ParsedError(json.dumps(payload), [])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def map_response_error(response: httpx.Response) -> MeterFlowError:
|
|
121
|
+
status = response.status_code
|
|
122
|
+
parsed = parse_error_body(response)
|
|
123
|
+
request_id = response.headers.get("x-request-id")
|
|
124
|
+
message, fields = parsed.message, parsed.fields
|
|
125
|
+
if status in (401, 403):
|
|
126
|
+
return AuthError(message, request_id=request_id, status_code=status)
|
|
127
|
+
if status == 402:
|
|
128
|
+
return InsufficientCreditsError(message, request_id=request_id)
|
|
129
|
+
if status == 404:
|
|
130
|
+
return NotFoundError(message, request_id=request_id)
|
|
131
|
+
if status == 409:
|
|
132
|
+
return ConflictError(message, request_id=request_id)
|
|
133
|
+
if status == 413:
|
|
134
|
+
return PayloadTooLargeError(message, request_id=request_id)
|
|
135
|
+
if status == 422:
|
|
136
|
+
return ValidationError(message, request_id=request_id, fields=fields)
|
|
137
|
+
if status == 429:
|
|
138
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
139
|
+
retry_after = int(retry_after_raw) if retry_after_raw is not None and retry_after_raw.strip().isdigit() else None
|
|
140
|
+
return RateLimitError(message, retry_after=retry_after, request_id=request_id)
|
|
141
|
+
if status >= 500:
|
|
142
|
+
return ServerError(message, request_id=request_id, status_code=status)
|
|
143
|
+
return MeterFlowError(message, "request_error", retryable=False, request_id=request_id, status_code=status)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def decode_response(response: httpx.Response) -> Any:
|
|
147
|
+
"""2xx → parsed JSON; ``204`` / an empty body → ``None`` (``subscriptions.delete``)."""
|
|
148
|
+
if response.status_code == 204 or not response.content:
|
|
149
|
+
return None
|
|
150
|
+
return response.json()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def request_kwargs(api_key: str, spec: RequestSpec) -> dict[str, Any]:
|
|
154
|
+
"""Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path."""
|
|
155
|
+
kwargs: dict[str, Any] = {"headers": build_headers(api_key, spec), "params": build_params(spec.query)}
|
|
156
|
+
if spec.body is not None:
|
|
157
|
+
kwargs["content"] = json.dumps(spec.body).encode("utf-8")
|
|
158
|
+
if spec.timeout is not None:
|
|
159
|
+
kwargs["timeout"] = spec.timeout
|
|
160
|
+
return kwargs
|
meterflow/_retry.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Retry policy shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
The policy only *decides* — how long to wait before attempt N+1, or that the caller should give up.
|
|
4
|
+
The clients do the sleeping (``time.sleep`` vs ``asyncio.sleep``), so the backoff maths lives once.
|
|
5
|
+
Mirrors the Node SDK: exponential base (1 s × factor^attempt, capped), ±25 % jitter, and a 429's
|
|
6
|
+
``Retry-After`` taken verbatim instead of the computed delay.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .errors import MeterFlowError, RateLimitError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RetryPolicy:
|
|
19
|
+
retries: int = 3
|
|
20
|
+
factor: float = 2.0
|
|
21
|
+
jitter: bool = True
|
|
22
|
+
max_delay_seconds: float = 10.0
|
|
23
|
+
|
|
24
|
+
def should_retry(self, error: BaseException) -> bool:
|
|
25
|
+
"""API errors say for themselves whether a retry can help; anything else is a transport failure and can."""
|
|
26
|
+
if isinstance(error, MeterFlowError):
|
|
27
|
+
return error.retryable
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
def delay_before_retry(self, attempt: int, error: BaseException) -> float | None:
|
|
31
|
+
"""Seconds to wait before retrying ``attempt`` (0-based), or ``None`` when the caller must re-raise."""
|
|
32
|
+
is_last = attempt >= self.retries
|
|
33
|
+
if is_last or not self.should_retry(error):
|
|
34
|
+
return None
|
|
35
|
+
if isinstance(error, RateLimitError) and error.retry_after is not None:
|
|
36
|
+
return float(error.retry_after)
|
|
37
|
+
base = min(1.0 * (self.factor**attempt), self.max_delay_seconds)
|
|
38
|
+
return base * (0.75 + random.random() * 0.5) if self.jitter else base # noqa: S311 — jitter, not security
|
meterflow/_version.py
ADDED
meterflow/client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""``MeterFlow`` (sync) and ``AsyncMeterFlow`` — same options, same resources, same errors.
|
|
2
|
+
|
|
3
|
+
Resources describe a call as a ``RequestSpec``; the client executes it with retries. Everything that
|
|
4
|
+
does not depend on *how* you wait lives in ``_http.py`` and ``_retry.py``, so the two clients are
|
|
5
|
+
thin and cannot drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from itertools import count
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._http import RequestSpec, _async_sleep, _sleep, decode_response, map_response_error, request_kwargs
|
|
18
|
+
from ._retry import RetryPolicy
|
|
19
|
+
from .resources.credits import AsyncCreditsResource, CreditsResource
|
|
20
|
+
from .resources.entitlements import AsyncEntitlementsResource, EntitlementsResource
|
|
21
|
+
from .resources.plans import AsyncPlansResource, PlansResource
|
|
22
|
+
from .resources.subscriptions import AsyncSubscriptionsResource, SubscriptionsResource
|
|
23
|
+
from .resources.usage import AsyncUsageResource, UsageResource
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.meter-flow.com/api/v1"
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
27
|
+
DEFAULT_RETRIES = 3
|
|
28
|
+
KEY_PREFIXES = ("mf_live_", "mf_test_")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ClientOptions:
|
|
33
|
+
"""The resolved configuration — what the Node SDK exposes as ``client.options``."""
|
|
34
|
+
|
|
35
|
+
api_key: str
|
|
36
|
+
base_url: str
|
|
37
|
+
timeout: float
|
|
38
|
+
retries: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _BaseClient:
|
|
42
|
+
def __init__(self, api_key: str, base_url: str | None, timeout: float | None, retries: int | None) -> None:
|
|
43
|
+
if not api_key.startswith(KEY_PREFIXES):
|
|
44
|
+
raise ValueError("api_key must start with mf_live_ or mf_test_")
|
|
45
|
+
self.options = ClientOptions(
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/") + "/",
|
|
48
|
+
timeout=DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout,
|
|
49
|
+
retries=DEFAULT_RETRIES if retries is None else retries,
|
|
50
|
+
)
|
|
51
|
+
self._retry = RetryPolicy(retries=self.options.retries)
|
|
52
|
+
|
|
53
|
+
def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
|
|
54
|
+
return request_kwargs(self.options.api_key, spec)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MeterFlow(_BaseClient):
|
|
58
|
+
"""Synchronous client.
|
|
59
|
+
|
|
60
|
+
``transport`` injects an ``httpx`` transport (tests, proxies) — the Python counterpart of the
|
|
61
|
+
Node SDK's ``fetch`` option. Use it as a context manager or call ``close()`` to release connections.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
api_key: str,
|
|
67
|
+
*,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
timeout: float | None = None,
|
|
70
|
+
retries: int | None = None,
|
|
71
|
+
transport: httpx.BaseTransport | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(api_key, base_url, timeout, retries)
|
|
74
|
+
self._http = httpx.Client(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
|
|
75
|
+
self.credits = CreditsResource(self)
|
|
76
|
+
self.usage = UsageResource(self)
|
|
77
|
+
self.subscriptions = SubscriptionsResource(self)
|
|
78
|
+
self.plans = PlansResource(self)
|
|
79
|
+
self.entitlements = EntitlementsResource(self)
|
|
80
|
+
|
|
81
|
+
def request(self, spec: RequestSpec) -> Any:
|
|
82
|
+
"""Internal — used by resource classes; not part of the public API."""
|
|
83
|
+
for attempt in count():
|
|
84
|
+
try:
|
|
85
|
+
response = self._http.request(spec.method, spec.path, **self._kwargs(spec))
|
|
86
|
+
if response.is_error:
|
|
87
|
+
raise map_response_error(response)
|
|
88
|
+
return decode_response(response)
|
|
89
|
+
except Exception as error:
|
|
90
|
+
delay = self._retry.delay_before_retry(attempt, error)
|
|
91
|
+
if delay is None:
|
|
92
|
+
raise
|
|
93
|
+
_sleep(delay)
|
|
94
|
+
raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
self._http.close()
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> MeterFlow:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AsyncMeterFlow(_BaseClient):
|
|
107
|
+
"""Asynchronous client — identical surface, every method awaitable. ``async with`` or ``aclose()``."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
api_key: str,
|
|
112
|
+
*,
|
|
113
|
+
base_url: str | None = None,
|
|
114
|
+
timeout: float | None = None,
|
|
115
|
+
retries: int | None = None,
|
|
116
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(api_key, base_url, timeout, retries)
|
|
119
|
+
self._http = httpx.AsyncClient(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
|
|
120
|
+
self.credits = AsyncCreditsResource(self)
|
|
121
|
+
self.usage = AsyncUsageResource(self)
|
|
122
|
+
self.subscriptions = AsyncSubscriptionsResource(self)
|
|
123
|
+
self.plans = AsyncPlansResource(self)
|
|
124
|
+
self.entitlements = AsyncEntitlementsResource(self)
|
|
125
|
+
|
|
126
|
+
async def request(self, spec: RequestSpec) -> Any:
|
|
127
|
+
"""Internal — used by resource classes; not part of the public API."""
|
|
128
|
+
for attempt in count():
|
|
129
|
+
try:
|
|
130
|
+
response = await self._http.request(spec.method, spec.path, **self._kwargs(spec))
|
|
131
|
+
if response.is_error:
|
|
132
|
+
raise map_response_error(response)
|
|
133
|
+
return decode_response(response)
|
|
134
|
+
except Exception as error:
|
|
135
|
+
delay = self._retry.delay_before_retry(attempt, error)
|
|
136
|
+
if delay is None:
|
|
137
|
+
raise
|
|
138
|
+
await _async_sleep(delay)
|
|
139
|
+
raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
|
|
140
|
+
|
|
141
|
+
async def aclose(self) -> None:
|
|
142
|
+
await self._http.aclose()
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self) -> AsyncMeterFlow:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
|
|
148
|
+
await self.aclose()
|
meterflow/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Typed error hierarchy — one class per HTTP outcome, mirroring the Node SDK one-to-one.
|
|
2
|
+
|
|
3
|
+
Every error carries the API's ``X-Request-ID`` (``request_id``) so a support ticket can point at the
|
|
4
|
+
exact request, plus ``status_code``, ``error_type`` and ``retryable`` for structured logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationFieldError(TypedDict):
|
|
13
|
+
"""One field the API rejected, as returned in a 422's ``error.fields``."""
|
|
14
|
+
|
|
15
|
+
field: str
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MeterFlowError(Exception):
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
error_type: str = "request_error",
|
|
24
|
+
*,
|
|
25
|
+
retryable: bool = False,
|
|
26
|
+
request_id: str | None = None,
|
|
27
|
+
status_code: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.error_type = error_type
|
|
32
|
+
self.retryable = retryable
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return self.message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthError(MeterFlowError):
|
|
41
|
+
"""401 / 403 — the key is missing, invalid, revoked or not allowed to do this."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 401) -> None:
|
|
44
|
+
super().__init__(message, "auth_error", retryable=False, request_id=request_id, status_code=status_code)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(MeterFlowError):
|
|
48
|
+
"""404 — no such customer, plan, subscription… or an unknown feature key (a typo, not a plan)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
51
|
+
super().__init__(message, "not_found", retryable=False, request_id=request_id, status_code=404)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InsufficientCreditsError(MeterFlowError):
|
|
55
|
+
"""402 — a deduction the balance cannot cover. A business outcome, not a failure: prompt a top-up."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
58
|
+
super().__init__(message, "insufficient_credits", retryable=False, request_id=request_id, status_code=402)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConflictError(MeterFlowError):
|
|
62
|
+
"""409 — the write clashes with current state (e.g. the project is deactivated)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
65
|
+
super().__init__(message, "conflict", retryable=False, request_id=request_id, status_code=409)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ValidationError(MeterFlowError):
|
|
69
|
+
"""422 — the API rejected the payload. ``fields`` holds the per-field detail (empty when none was sent)."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str, *, request_id: str | None = None, fields: list[ValidationFieldError] | None = None) -> None:
|
|
72
|
+
super().__init__(message, "validation_error", retryable=False, request_id=request_id, status_code=422)
|
|
73
|
+
self.fields: list[ValidationFieldError] = list(fields or [])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PayloadTooLargeError(MeterFlowError):
|
|
77
|
+
"""413 — well-formed but too big; today only ``usage.record_batch`` above ``MAX_BATCH_EVENTS``.
|
|
78
|
+
|
|
79
|
+
Never retried: the same payload would be refused again. Split it at the call site.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
83
|
+
super().__init__(message, "payload_too_large", retryable=False, request_id=request_id, status_code=413)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RateLimitError(MeterFlowError):
|
|
87
|
+
"""429 — retried automatically after the server's ``Retry-After`` (seconds, exposed as ``retry_after``)."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, *, retry_after: int | None = None, request_id: str | None = None) -> None:
|
|
90
|
+
super().__init__(message, "rate_limit", retryable=True, request_id=request_id, status_code=429)
|
|
91
|
+
self.retry_after = retry_after
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ServerError(MeterFlowError):
|
|
95
|
+
"""5xx — MeterFlow's side; retried automatically with backoff."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 500) -> None:
|
|
98
|
+
super().__init__(message, "server_error", retryable=True, request_id=request_id, status_code=status_code)
|
meterflow/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The five SDK-facing resources, each in a sync and an async flavour sharing one set of request specs."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Credits — an append-only ledger per customer: grant up, deduct down, never edited in place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .._http import RequestSpec, encode_path_segment
|
|
8
|
+
from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionResponse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def grant_spec(body: CreditGrantRequest, idempotency_key: str | None) -> RequestSpec:
|
|
15
|
+
return RequestSpec("POST", "credits/grant", body=body, idempotency_key=idempotency_key)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def deduct_spec(body: CreditDeductRequest, idempotency_key: str | None) -> RequestSpec:
|
|
19
|
+
return RequestSpec("POST", "credits/deduct", body=body, idempotency_key=idempotency_key)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def balance_spec(customer_id: str) -> RequestSpec:
|
|
23
|
+
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/balance")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def transactions_spec(customer_id: str) -> RequestSpec:
|
|
27
|
+
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CreditsResource:
|
|
31
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
35
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
36
|
+
result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
40
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
|
|
41
|
+
result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
45
|
+
"""The customer's current position — amounts are decimal strings."""
|
|
46
|
+
result: CreditBalanceResponse = self._client.request(balance_spec(customer_id))
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
|
|
50
|
+
"""The full history, newest first."""
|
|
51
|
+
result: list[CreditTransactionResponse] = self._client.request(transactions_spec(customer_id))
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AsyncCreditsResource:
|
|
56
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
57
|
+
self._client = client
|
|
58
|
+
|
|
59
|
+
async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
60
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
61
|
+
result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
async def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
65
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
|
|
66
|
+
result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
async def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
70
|
+
"""The customer's current position — amounts are decimal strings."""
|
|
71
|
+
result: CreditBalanceResponse = await self._client.request(balance_spec(customer_id))
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
async def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
|
|
75
|
+
"""The full history, newest first."""
|
|
76
|
+
result: list[CreditTransactionResponse] = await self._client.request(transactions_spec(customer_id))
|
|
77
|
+
return result
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Entitlements — ask BEFORE doing the work.
|
|
2
|
+
|
|
3
|
+
``check()`` is where a hard limit says no; recording usage afterwards never blocks, so an app that
|
|
4
|
+
skips this call gets no enforcement at all — only an honest ledger.
|
|
5
|
+
|
|
6
|
+
``allowed: False`` is a normal answer, not an error: the call returns and ``reason`` says why
|
|
7
|
+
(``no_subscription``, ``not_in_plan``, ``hard_limit_reached``, ``insufficient_credits``). A feature
|
|
8
|
+
key the project does not know at all is a 404 → ``NotFoundError``, because that is a typo, not a plan.
|
|
9
|
+
|
|
10
|
+
Both calls return ``Cache-Control: private, max-age=15`` — cache per customer for a few seconds on
|
|
11
|
+
hot paths rather than calling on every request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from .._http import RequestSpec, encode_path_segment
|
|
19
|
+
from ..types import EntitlementResponse, EntitlementsResponse
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_spec(customer_id: str) -> RequestSpec:
|
|
26
|
+
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_spec(customer_id: str, feature: str, quantity: int | None) -> RequestSpec:
|
|
30
|
+
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}/check", query={"feature": feature, "quantity": quantity})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EntitlementsResource:
|
|
34
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
def get(self, customer_id: str) -> EntitlementsResponse:
|
|
38
|
+
"""Every feature on the customer's plan, with this period's usage. Empty ``entitlements`` without a subscription."""
|
|
39
|
+
result: EntitlementsResponse = self._client.request(get_spec(customer_id))
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
43
|
+
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
44
|
+
result: EntitlementResponse = self._client.request(check_spec(customer_id, feature, quantity))
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncEntitlementsResource:
|
|
49
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
50
|
+
self._client = client
|
|
51
|
+
|
|
52
|
+
async def get(self, customer_id: str) -> EntitlementsResponse:
|
|
53
|
+
"""Every feature on the customer's plan, with this period's usage. Empty ``entitlements`` without a subscription."""
|
|
54
|
+
result: EntitlementsResponse = await self._client.request(get_spec(customer_id))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
58
|
+
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
59
|
+
result: EntitlementResponse = await self._client.request(check_spec(customer_id, feature, quantity))
|
|
60
|
+
return result
|