meterflow 0.4.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {meterflow-0.4.0 → meterflow-0.5.0}/PKG-INFO +30 -2
  2. {meterflow-0.4.0 → meterflow-0.5.0}/README.md +29 -1
  3. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/__init__.py +6 -0
  4. meterflow-0.5.0/src/meterflow/_entitlement_cache.py +134 -0
  5. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/_version.py +1 -1
  6. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/client.py +47 -8
  7. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/credits.py +15 -4
  8. meterflow-0.5.0/src/meterflow/resources/entitlements.py +223 -0
  9. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/usage.py +13 -2
  10. meterflow-0.4.0/src/meterflow/resources/entitlements.py +0 -66
  11. {meterflow-0.4.0 → meterflow-0.5.0}/LICENSE +0 -0
  12. {meterflow-0.4.0 → meterflow-0.5.0}/pyproject.toml +0 -0
  13. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/_http.py +0 -0
  14. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/_retry.py +0 -0
  15. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/errors.py +0 -0
  16. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/py.typed +0 -0
  17. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/__init__.py +0 -0
  18. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/plans.py +0 -0
  19. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/portal.py +0 -0
  20. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/subscriptions.py +0 -0
  21. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/resources/webhooks.py +0 -0
  22. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/types.py +0 -0
  23. {meterflow-0.4.0 → meterflow-0.5.0}/src/meterflow/webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterflow
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
5
5
  Project-URL: Homepage, https://meter-flow.com
6
6
  Project-URL: Documentation, https://meter-flow.com/guide
@@ -190,6 +190,9 @@ client = MeterFlow(
190
190
  timeout=30.0, # optional — per-request timeout in seconds (default 30)
191
191
  retries=3, # optional — automatic retries (default 3, 0 disables)
192
192
  transport=None, # optional — an httpx transport (tests, proxies)
193
+ entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
194
+ entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
195
+ on_error=None, # optional — told when an entitlements call answers from a fallback
193
196
  )
194
197
  ```
195
198
 
@@ -463,7 +466,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
463
466
  - A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
464
467
  - A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
465
468
  - `allowed: False` is a normal returned answer, never an exception. A feature key the project does not know at all is a `NotFoundError` — that is a typo, not a plan.
466
- - The answer carries `Cache-Control: private, max-age=15`; on hot paths cache it per customer for a few seconds rather than calling on every request.
469
+ - The SDK reuses an answer for the `Cache-Control: max-age` it came with (15 s today), per customer, feature and `quantity` — `allowed: False` included. See [When MeterFlow can't be reached](#when-meterflow-cant-be-reached).
467
470
 
468
471
  **All of them at once** — for a settings or pricing screen:
469
472
 
@@ -478,6 +481,31 @@ Each metered row answers for the **next** unit, exactly as `check()` would, so i
478
481
 
479
482
  Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
480
483
 
484
+ #### When MeterFlow can't be reached
485
+
486
+ A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
487
+
488
+ - **The cache.** Answers from `check()` and `get()` are reused for the `max-age` the API sends (15 s), per client, up to 1,000 of them. A response without `Cache-Control` is never cached. A successful `usage.record()`, `usage.record_batch()`, `credits.grant()` or `credits.deduct()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
489
+ - **The last answer.** When the API cannot be reached — a network error, a timeout, a 5xx or a 429, once the retries are spent — a call returns its last answer for that customer and feature, past its age, with `"stale": True`.
490
+ - **Your defaults.** With no earlier answer, `check()` returns your `entitlement_defaults` entry for the feature, else its `"*"` entry: `{"feature", "feature_type": None, "allowed", "reason": "offline_default"}`. With neither, it raises as before. `get()` has no default: a whole plan cannot be guessed.
491
+
492
+ ```python
493
+ from meterflow import EntitlementFallbackContext, MeterFlow
494
+
495
+ def warn(error: Exception, context: EntitlementFallbackContext) -> None:
496
+ log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
497
+
498
+ client = MeterFlow(
499
+ api_key=os.environ["METERFLOW_API_KEY"],
500
+ timeout=2.0, # a gate on your hot path should give up fast
501
+ retries=1,
502
+ entitlement_defaults={"images.generated": "allow", "*": "deny"},
503
+ on_error=warn,
504
+ )
505
+ ```
506
+
507
+ A 401, 403, 404 or 422 is an answer, not an outage: it is raised, never replaced by a fallback. `on_error` is called before a fallback is used; anything it raises is ignored. `check()` returns an `EntitlementCheck` and `get()` an `EntitlementList` (both exported from `meterflow`): the API's types, widened for `stale` and `offline_default`.
508
+
481
509
  ### The balance portal — a page you don't have to build
482
510
 
483
511
  Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
@@ -138,6 +138,9 @@ client = MeterFlow(
138
138
  timeout=30.0, # optional — per-request timeout in seconds (default 30)
139
139
  retries=3, # optional — automatic retries (default 3, 0 disables)
140
140
  transport=None, # optional — an httpx transport (tests, proxies)
141
+ entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
142
+ entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
143
+ on_error=None, # optional — told when an entitlements call answers from a fallback
141
144
  )
142
145
  ```
143
146
 
@@ -411,7 +414,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
411
414
  - A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
412
415
  - A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
413
416
  - `allowed: False` is a normal returned answer, never an exception. A feature key the project does not know at all is a `NotFoundError` — that is a typo, not a plan.
414
- - The answer carries `Cache-Control: private, max-age=15`; on hot paths cache it per customer for a few seconds rather than calling on every request.
417
+ - The SDK reuses an answer for the `Cache-Control: max-age` it came with (15 s today), per customer, feature and `quantity` — `allowed: False` included. See [When MeterFlow can't be reached](#when-meterflow-cant-be-reached).
415
418
 
416
419
  **All of them at once** — for a settings or pricing screen:
417
420
 
@@ -426,6 +429,31 @@ Each metered row answers for the **next** unit, exactly as `check()` would, so i
426
429
 
427
430
  Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
428
431
 
432
+ #### When MeterFlow can't be reached
433
+
434
+ A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
435
+
436
+ - **The cache.** Answers from `check()` and `get()` are reused for the `max-age` the API sends (15 s), per client, up to 1,000 of them. A response without `Cache-Control` is never cached. A successful `usage.record()`, `usage.record_batch()`, `credits.grant()` or `credits.deduct()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
437
+ - **The last answer.** When the API cannot be reached — a network error, a timeout, a 5xx or a 429, once the retries are spent — a call returns its last answer for that customer and feature, past its age, with `"stale": True`.
438
+ - **Your defaults.** With no earlier answer, `check()` returns your `entitlement_defaults` entry for the feature, else its `"*"` entry: `{"feature", "feature_type": None, "allowed", "reason": "offline_default"}`. With neither, it raises as before. `get()` has no default: a whole plan cannot be guessed.
439
+
440
+ ```python
441
+ from meterflow import EntitlementFallbackContext, MeterFlow
442
+
443
+ def warn(error: Exception, context: EntitlementFallbackContext) -> None:
444
+ log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
445
+
446
+ client = MeterFlow(
447
+ api_key=os.environ["METERFLOW_API_KEY"],
448
+ timeout=2.0, # a gate on your hot path should give up fast
449
+ retries=1,
450
+ entitlement_defaults={"images.generated": "allow", "*": "deny"},
451
+ on_error=warn,
452
+ )
453
+ ```
454
+
455
+ A 401, 403, 404 or 422 is an answer, not an outage: it is raised, never replaced by a fallback. `on_error` is called before a fallback is used; anything it raises is ignored. `check()` returns an `EntitlementCheck` and `get()` an `EntitlementList` (both exported from `meterflow`): the API's types, widened for `stale` and `offline_default`.
456
+
429
457
  ### The balance portal — a page you don't have to build
430
458
 
431
459
  Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
@@ -6,6 +6,7 @@ client = MeterFlow(api_key="mf_live_...")
6
6
  client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "metadata": {}})
7
7
  """
8
8
 
9
+ from ._entitlement_cache import EntitlementDefault, EntitlementFallbackContext
9
10
  from ._version import __version__
10
11
  from .client import AsyncMeterFlow, ClientOptions, MeterFlow
11
12
  from .errors import (
@@ -20,6 +21,7 @@ from .errors import (
20
21
  ValidationError,
21
22
  ValidationFieldError,
22
23
  )
24
+ from .resources.entitlements import EntitlementCheck, EntitlementList
23
25
  from .resources.usage import MAX_BATCH_EVENTS
24
26
  from .webhook import verify_webhook
25
27
 
@@ -39,5 +41,9 @@ __all__ = [
39
41
  "RateLimitError",
40
42
  "ServerError",
41
43
  "MAX_BATCH_EVENTS",
44
+ "EntitlementCheck",
45
+ "EntitlementList",
46
+ "EntitlementDefault",
47
+ "EntitlementFallbackContext",
42
48
  "verify_webhook",
43
49
  ]
@@ -0,0 +1,134 @@
1
+ """The entitlement answers one client keeps, and the rules for falling back on them. Mirrors ``sdks/node/src/utils/entitlement-cache.ts``.
2
+
3
+ Each answer is fresh for the ``max-age`` the API sends and kept past it as the fallback for an outage.
4
+ Bounded, least recently used out first.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import time
11
+ from collections import OrderedDict
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from typing import Any, Literal
15
+
16
+ from .errors import MeterFlowError, RateLimitError, ServerError
17
+
18
+ # How many answers one client keeps; the least recently used goes first past it.
19
+ MAX_CACHED_ENTITLEMENTS = 1_000
20
+
21
+ EntitlementDefault = Literal["allow", "deny"]
22
+
23
+ # The clock answers are aged by; an indirection so tests can step it.
24
+ _now = time.monotonic
25
+
26
+ _MAX_AGE = re.compile(r"\bmax-age=(\d+)", re.IGNORECASE)
27
+ _NO_STORE = re.compile(r"\bno-(store|cache)\b", re.IGNORECASE)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class EntitlementFallbackContext:
32
+ """Which call fell back, and to what, as ``on_error`` is told.
33
+
34
+ customer_id: the customer the call was for.
35
+ feature: the feature checked; None for ``entitlements.get()``.
36
+ fallback: ``stale``, the last answer past its ``max-age``; ``default``, the configured ``entitlement_defaults`` entry.
37
+ """
38
+
39
+ customer_id: str
40
+ feature: str | None
41
+ fallback: Literal["stale", "default"]
42
+
43
+
44
+ OnEntitlementError = Callable[[Exception, EntitlementFallbackContext], None]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class CacheRead:
49
+ """An answer found in the cache, and whether it is still fresh."""
50
+
51
+ value: Any
52
+ fresh: bool
53
+
54
+
55
+ @dataclass
56
+ class _CachedAnswer:
57
+ customer_id: str
58
+ value: Any
59
+ fresh_until: float
60
+
61
+
62
+ def max_age_seconds(header: str | None) -> int:
63
+ """The seconds a response may be reused for: its ``max-age``; 0 when absent, ``no-store`` / ``no-cache``, or naming none.
64
+
65
+ header: the response's ``Cache-Control`` value, or None.
66
+ """
67
+ if not header or _NO_STORE.search(header):
68
+ return 0
69
+ match = _MAX_AGE.search(header)
70
+ return int(match.group(1)) if match else 0
71
+
72
+
73
+ def is_outage(error: BaseException) -> bool:
74
+ """Whether a failed call is MeterFlow being unreachable rather than an answer: no response at all, a 5xx after the retries, or a 429.
75
+
76
+ error: what the call raised.
77
+ """
78
+ if not isinstance(error, MeterFlowError):
79
+ return True
80
+ return isinstance(error, ServerError | RateLimitError)
81
+
82
+
83
+ class EntitlementCache:
84
+ """The entitlement answers one client has read, each fresh for the API's ``max-age`` and kept past it as an outage fallback."""
85
+
86
+ def __init__(self) -> None:
87
+ self._answers: OrderedDict[str, _CachedAnswer] = OrderedDict()
88
+
89
+ def read(self, key: str) -> CacheRead | None:
90
+ """The answer stored under ``key``, with whether it is still fresh; None when there is none.
91
+
92
+ key: the call's cache key.
93
+ """
94
+ cached = self._answers.get(key)
95
+ if cached is None:
96
+ return None
97
+ self._answers.move_to_end(key)
98
+ return CacheRead(cached.value, _now() < cached.fresh_until)
99
+
100
+ def write(self, key: str, customer_id: str, value: Any, seconds: int) -> None:
101
+ """Keep an answer for ``seconds``; nothing is kept when that is 0.
102
+
103
+ key: the call's cache key.
104
+ customer_id: whose answer it is, so a write for them can expire it.
105
+ value: the answer.
106
+ seconds: how long it stays fresh.
107
+ """
108
+ if seconds <= 0:
109
+ return
110
+ self._answers[key] = _CachedAnswer(customer_id, value, _now() + seconds)
111
+ self._answers.move_to_end(key)
112
+ if len(self._answers) > MAX_CACHED_ENTITLEMENTS:
113
+ self._answers.popitem(last=False)
114
+
115
+ def invalidate(self, customer_id: str) -> None:
116
+ """Make every answer for a customer stale, after a write that changed what they may do. They stay as an outage fallback.
117
+
118
+ customer_id: the customer written for.
119
+ """
120
+ for cached in self._answers.values():
121
+ if cached.customer_id == customer_id:
122
+ cached.fresh_until = 0.0
123
+
124
+
125
+ def expire_entitlements(cache: EntitlementCache | None, customer_ids: list[str]) -> None:
126
+ """Make the cached answers of every customer a successful write touched stale; nothing when the cache is off.
127
+
128
+ cache: the client's cache, or None.
129
+ customer_ids: the customers written for, repeats allowed.
130
+ """
131
+ if cache is None:
132
+ return
133
+ for customer_id in set(customer_ids):
134
+ cache.invalidate(customer_id)
@@ -1,3 +1,3 @@
1
1
  # The one place the version lives. pyproject.toml reads it (hatch dynamic version) and the
2
2
  # User-Agent header reports it, so a release bump edits this file and nothing else.
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -7,13 +7,15 @@ thin and cannot drift apart.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from dataclasses import dataclass
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import dataclass, field
11
12
  from itertools import count
12
13
  from types import TracebackType
13
14
  from typing import Any
14
15
 
15
16
  import httpx
16
17
 
18
+ from ._entitlement_cache import EntitlementCache, EntitlementDefault, OnEntitlementError
17
19
  from ._http import RequestSpec, _async_sleep, _sleep, decode_response, map_response_error, request_kwargs
18
20
  from ._retry import RetryPolicy
19
21
  from .resources.credits import AsyncCreditsResource, CreditsResource
@@ -38,10 +40,22 @@ class ClientOptions:
38
40
  base_url: str
39
41
  timeout: float
40
42
  retries: int
43
+ entitlements_cache: bool = True
44
+ entitlement_defaults: Mapping[str, EntitlementDefault] = field(default_factory=dict)
45
+ on_error: OnEntitlementError | None = None
41
46
 
42
47
 
43
48
  class _BaseClient:
44
- def __init__(self, api_key: str, base_url: str | None, timeout: float | None, retries: int | None) -> None:
49
+ def __init__(
50
+ self,
51
+ api_key: str,
52
+ base_url: str | None,
53
+ timeout: float | None,
54
+ retries: int | None,
55
+ entitlements_cache: bool,
56
+ entitlement_defaults: Mapping[str, EntitlementDefault] | None,
57
+ on_error: OnEntitlementError | None,
58
+ ) -> None:
45
59
  if not api_key.startswith(KEY_PREFIXES):
46
60
  raise ValueError("api_key must start with mf_live_ or mf_test_")
47
61
  self.options = ClientOptions(
@@ -49,8 +63,13 @@ class _BaseClient:
49
63
  base_url=(base_url or DEFAULT_BASE_URL).rstrip("/") + "/",
50
64
  timeout=DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout,
51
65
  retries=DEFAULT_RETRIES if retries is None else retries,
66
+ entitlements_cache=entitlements_cache,
67
+ entitlement_defaults=dict(entitlement_defaults or {}),
68
+ on_error=on_error,
52
69
  )
53
70
  self._retry = RetryPolicy(retries=self.options.retries)
71
+ # Internal: the entitlement answers this client has read; None when ``entitlements_cache`` is False.
72
+ self.entitlement_cache = EntitlementCache() if entitlements_cache else None
54
73
 
55
74
  def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
56
75
  return request_kwargs(self.options.api_key, spec)
@@ -61,6 +80,8 @@ class MeterFlow(_BaseClient):
61
80
 
62
81
  ``transport`` injects an ``httpx`` transport (tests, proxies) — the Python counterpart of the
63
82
  Node SDK's ``fetch`` option. Use it as a context manager or call ``close()`` to release connections.
83
+ ``entitlements_cache``, ``entitlement_defaults`` and ``on_error`` set how ``entitlements`` caches its
84
+ answers and what it answers while MeterFlow is unreachable (``resources/entitlements.py``).
64
85
  """
65
86
 
66
87
  def __init__(
@@ -71,8 +92,11 @@ class MeterFlow(_BaseClient):
71
92
  timeout: float | None = None,
72
93
  retries: int | None = None,
73
94
  transport: httpx.BaseTransport | None = None,
95
+ entitlements_cache: bool = True,
96
+ entitlement_defaults: Mapping[str, EntitlementDefault] | None = None,
97
+ on_error: OnEntitlementError | None = None,
74
98
  ) -> None:
75
- super().__init__(api_key, base_url, timeout, retries)
99
+ super().__init__(api_key, base_url, timeout, retries, entitlements_cache, entitlement_defaults, on_error)
76
100
  self._http = httpx.Client(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
77
101
  self.credits = CreditsResource(self)
78
102
  self.usage = UsageResource(self)
@@ -82,13 +106,19 @@ class MeterFlow(_BaseClient):
82
106
  self.entitlements = EntitlementsResource(self)
83
107
  self.webhooks = WebhooksResource(self)
84
108
 
85
- def request(self, spec: RequestSpec) -> Any:
86
- """Internal — used by resource classes; not part of the public API."""
109
+ def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
110
+ """Internal — used by resource classes; not part of the public API.
111
+
112
+ spec: the call.
113
+ on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
114
+ """
87
115
  for attempt in count():
88
116
  try:
89
117
  response = self._http.request(spec.method, spec.path, **self._kwargs(spec))
90
118
  if response.is_error:
91
119
  raise map_response_error(response)
120
+ if on_headers is not None:
121
+ on_headers(response.headers)
92
122
  return decode_response(response)
93
123
  except Exception as error:
94
124
  delay = self._retry.delay_before_retry(attempt, error)
@@ -118,8 +148,11 @@ class AsyncMeterFlow(_BaseClient):
118
148
  timeout: float | None = None,
119
149
  retries: int | None = None,
120
150
  transport: httpx.AsyncBaseTransport | None = None,
151
+ entitlements_cache: bool = True,
152
+ entitlement_defaults: Mapping[str, EntitlementDefault] | None = None,
153
+ on_error: OnEntitlementError | None = None,
121
154
  ) -> None:
122
- super().__init__(api_key, base_url, timeout, retries)
155
+ super().__init__(api_key, base_url, timeout, retries, entitlements_cache, entitlement_defaults, on_error)
123
156
  self._http = httpx.AsyncClient(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
124
157
  self.credits = AsyncCreditsResource(self)
125
158
  self.usage = AsyncUsageResource(self)
@@ -129,13 +162,19 @@ class AsyncMeterFlow(_BaseClient):
129
162
  self.entitlements = AsyncEntitlementsResource(self)
130
163
  self.webhooks = AsyncWebhooksResource(self)
131
164
 
132
- async def request(self, spec: RequestSpec) -> Any:
133
- """Internal — used by resource classes; not part of the public API."""
165
+ async def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
166
+ """Internal — used by resource classes; not part of the public API.
167
+
168
+ spec: the call.
169
+ on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
170
+ """
134
171
  for attempt in count():
135
172
  try:
136
173
  response = await self._http.request(spec.method, spec.path, **self._kwargs(spec))
137
174
  if response.is_error:
138
175
  raise map_response_error(response)
176
+ if on_headers is not None:
177
+ on_headers(response.headers)
139
178
  return decode_response(response)
140
179
  except Exception as error:
141
180
  delay = self._retry.delay_before_retry(attempt, error)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from .._entitlement_cache import expire_entitlements
7
8
  from .._http import RequestSpec, encode_path_segment
8
9
  from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionPage, CreditTransactionResponse
9
10
 
@@ -32,13 +33,18 @@ class CreditsResource:
32
33
  self._client = client
33
34
 
34
35
  def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
35
- """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
36
+ """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
36
37
  result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
38
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
37
39
  return result
38
40
 
39
41
  def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
40
- """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
42
+ """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
43
+
44
+ The customer's cached entitlement answers go stale.
45
+ """
41
46
  result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
47
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
42
48
  return result
43
49
 
44
50
  def balance(self, customer_id: str) -> CreditBalanceResponse:
@@ -62,13 +68,18 @@ class AsyncCreditsResource:
62
68
  self._client = client
63
69
 
64
70
  async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
65
- """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
71
+ """Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
66
72
  result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
73
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
67
74
  return result
68
75
 
69
76
  async def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
70
- """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
77
+ """Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
78
+
79
+ The customer's cached entitlement answers go stale.
80
+ """
71
81
  result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
82
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
72
83
  return result
73
84
 
74
85
  async def balance(self, customer_id: str) -> CreditBalanceResponse:
@@ -0,0 +1,223 @@
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
+ Answers are reused for the ``Cache-Control: max-age`` the API sends (15 s), per customer, feature and
11
+ quantity, unless ``entitlements_cache=False``. A usage record, grant or deduct for a customer makes
12
+ their answers stale. While MeterFlow is unreachable, a call returns its last answer with
13
+ ``stale: True``, or else ``check()`` returns the ``entitlement_defaults`` entry for the feature.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict
20
+
21
+ import httpx
22
+
23
+ from .._entitlement_cache import CacheRead, EntitlementFallbackContext, is_outage, max_age_seconds
24
+ from .._http import RequestSpec, encode_path_segment
25
+ from ..types import EntitlementReason, EntitlementsResponse, FeatureType
26
+
27
+ if TYPE_CHECKING:
28
+ from ..client import AsyncMeterFlow, MeterFlow, _BaseClient
29
+
30
+
31
+ class _EntitlementCheckRequired(TypedDict):
32
+ feature: str
33
+ feature_type: FeatureType | None
34
+ allowed: bool
35
+
36
+
37
+ class EntitlementCheck(_EntitlementCheckRequired, total=False):
38
+ """What ``check()`` returns: the API's ``EntitlementResponse``, or a fallback while MeterFlow is unreachable.
39
+
40
+ ``stale: True`` marks the last answer reused past its ``max-age``; ``reason: "offline_default"`` marks
41
+ your ``entitlement_defaults`` entry, which knows nothing of the plan, so its ``feature_type`` is None.
42
+ The other keys are ``EntitlementResponse``'s, repeated because a generated type cannot be widened.
43
+ """
44
+
45
+ reason: EntitlementReason | Literal["offline_default"] | None
46
+ limit_type: str | None
47
+ included: int | None
48
+ used: str | None
49
+ remaining: str | None
50
+ period_start: str | None
51
+ period_end: str | None
52
+ stale: bool
53
+
54
+
55
+ class EntitlementList(EntitlementsResponse, total=False):
56
+ """What ``get()`` returns: the API's answer, with ``stale: True`` when it is the last one reused during an outage."""
57
+
58
+ stale: bool
59
+
60
+
61
+ def get_spec(customer_id: str) -> RequestSpec:
62
+ return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}")
63
+
64
+
65
+ def check_spec(customer_id: str, feature: str, quantity: int | None) -> RequestSpec:
66
+ return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}/check", query={"feature": feature, "quantity": quantity})
67
+
68
+
69
+ def _get_key(customer_id: str) -> str:
70
+ return f"get\0{customer_id}"
71
+
72
+
73
+ def _check_key(customer_id: str, feature: str, quantity: int | None) -> str:
74
+ return f"check\0{customer_id}\0{feature}\0{quantity or 1}"
75
+
76
+
77
+ class _Answer:
78
+ """One entitlements call's cache handling, shared by the sync and async resources, which only differ in how they wait.
79
+
80
+ client: the client whose cache and options apply.
81
+ key: the call's cache key.
82
+ customer_id: the customer the call is for.
83
+ feature: the feature checked; None for ``get()``, which has no default.
84
+ """
85
+
86
+ def __init__(self, client: _BaseClient, key: str, customer_id: str, feature: str | None) -> None:
87
+ self._client = client
88
+ self._key = key
89
+ self._customer_id = customer_id
90
+ self._feature = feature
91
+ self._max_age = 0
92
+ self.cached: CacheRead | None = client.entitlement_cache.read(key) if client.entitlement_cache is not None else None
93
+
94
+ def read_headers(self, headers: httpx.Headers) -> None:
95
+ """Note the response's ``max-age``.
96
+
97
+ headers: the successful response's headers.
98
+ """
99
+ self._max_age = max_age_seconds(headers.get("cache-control"))
100
+
101
+ def keep(self, value: Any) -> Any:
102
+ """Store a fresh answer for its ``max-age``, and return it.
103
+
104
+ value: the API's answer.
105
+ """
106
+ if self._client.entitlement_cache is not None:
107
+ self._client.entitlement_cache.write(self._key, self._customer_id, value, self._max_age)
108
+ return value
109
+
110
+ def fallback(self, error: Exception) -> Any:
111
+ """The answer for a failed call: the last one marked stale, else the feature's default.
112
+
113
+ Re-raises ``error`` when it was an answer rather than an outage, or when there is nothing to fall back on.
114
+
115
+ error: what the call raised.
116
+ """
117
+ if not is_outage(error):
118
+ raise error
119
+ if self.cached is not None:
120
+ self._tell(error, "stale")
121
+ return {**self.cached.value, "stale": True}
122
+ default = None if self._feature is None else self._offline_default(self._feature)
123
+ if default is None:
124
+ raise error
125
+ self._tell(error, "default")
126
+ return default
127
+
128
+ def _offline_default(self, feature: str) -> EntitlementCheck | None:
129
+ """The configured answer for a feature: its own entry, else ``"*"``; None when neither is set.
130
+
131
+ feature: the feature checked.
132
+ """
133
+ defaults = self._client.options.entitlement_defaults
134
+ setting = defaults.get(feature, defaults.get("*"))
135
+ if setting is None:
136
+ return None
137
+ return {"feature": feature, "feature_type": None, "allowed": setting == "allow", "reason": "offline_default"}
138
+
139
+ def _tell(self, error: Exception, fallback: Literal["stale", "default"]) -> None:
140
+ """Hand the error and the fallback to ``on_error``, ignoring anything it raises: it is the caller's code.
141
+
142
+ error: what the call raised.
143
+ fallback: what the call answers with instead.
144
+ """
145
+ on_error: Callable[[Exception, EntitlementFallbackContext], None] | None = self._client.options.on_error
146
+ if on_error is None:
147
+ return
148
+ try:
149
+ on_error(error, EntitlementFallbackContext(self._customer_id, self._feature, fallback))
150
+ except Exception: # noqa: S110 — the hook is the caller's; its failure must not undo the fallback
151
+ pass
152
+
153
+
154
+ class EntitlementsResource:
155
+ def __init__(self, client: MeterFlow) -> None:
156
+ self._client = client
157
+
158
+ def get(self, customer_id: str) -> EntitlementList:
159
+ """Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
160
+
161
+ Empty ``entitlements`` without a subscription.
162
+ customer_id: the customer asked about.
163
+ """
164
+ return self._answer(_Answer(self._client, _get_key(customer_id), customer_id, None), get_spec(customer_id)) # type: ignore[no-any-return]
165
+
166
+ def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementCheck:
167
+ """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?
168
+
169
+ customer_id: the customer asked about.
170
+ feature: a meter's event name or a plan feature key.
171
+ quantity: how many more units the app is about to use; 1 on the server when None.
172
+ """
173
+ answer = _Answer(self._client, _check_key(customer_id, feature, quantity), customer_id, feature)
174
+ return self._answer(answer, check_spec(customer_id, feature, quantity)) # type: ignore[no-any-return]
175
+
176
+ def _answer(self, answer: _Answer, spec: RequestSpec) -> Any:
177
+ """Return the fresh cached answer, else the API's, else a fallback.
178
+
179
+ answer: the call's cache handling.
180
+ spec: the request to make when nothing fresh is cached.
181
+ """
182
+ if answer.cached is not None and answer.cached.fresh:
183
+ return answer.cached.value
184
+ try:
185
+ return answer.keep(self._client.request(spec, answer.read_headers))
186
+ except Exception as error:
187
+ return answer.fallback(error)
188
+
189
+
190
+ class AsyncEntitlementsResource:
191
+ def __init__(self, client: AsyncMeterFlow) -> None:
192
+ self._client = client
193
+
194
+ async def get(self, customer_id: str) -> EntitlementList:
195
+ """Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
196
+
197
+ Empty ``entitlements`` without a subscription.
198
+ customer_id: the customer asked about.
199
+ """
200
+ return await self._answer(_Answer(self._client, _get_key(customer_id), customer_id, None), get_spec(customer_id)) # type: ignore[no-any-return]
201
+
202
+ async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementCheck:
203
+ """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?
204
+
205
+ customer_id: the customer asked about.
206
+ feature: a meter's event name or a plan feature key.
207
+ quantity: how many more units the app is about to use; 1 on the server when None.
208
+ """
209
+ answer = _Answer(self._client, _check_key(customer_id, feature, quantity), customer_id, feature)
210
+ return await self._answer(answer, check_spec(customer_id, feature, quantity)) # type: ignore[no-any-return]
211
+
212
+ async def _answer(self, answer: _Answer, spec: RequestSpec) -> Any:
213
+ """Return the fresh cached answer, else the API's, else a fallback.
214
+
215
+ answer: the call's cache handling.
216
+ spec: the request to make when nothing fresh is cached.
217
+ """
218
+ if answer.cached is not None and answer.cached.fresh:
219
+ return answer.cached.value
220
+ try:
221
+ return answer.keep(await self._client.request(spec, answer.read_headers))
222
+ except Exception as error:
223
+ return answer.fallback(error)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from .._entitlement_cache import expire_entitlements
7
8
  from .._http import RequestSpec, encode_path_segment
8
9
  from ..errors import PayloadTooLargeError
9
10
  from ..types import UsageEventRequest, UsageEventResponse, UsageSummaryResponse
@@ -42,13 +43,18 @@ class UsageResource:
42
43
  self._client = client
43
44
 
44
45
  def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
45
- """Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
46
+ """Record one event. Returns immediately (``processed: False``); totals materialise moments later.
47
+
48
+ The customer's cached entitlement answers go stale.
49
+ """
46
50
  result: UsageEventResponse = self._client.request(record_spec(body, idempotency_key))
51
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
47
52
  return result
48
53
 
49
54
  def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
50
55
  """Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
51
56
  result: list[UsageEventResponse] = self._client.request(record_batch_spec(events, idempotency_key))
57
+ expire_entitlements(self._client.entitlement_cache, [event["customer_external_id"] for event in events])
52
58
  return result
53
59
 
54
60
  def event(self, event_id: str) -> UsageEventResponse:
@@ -74,13 +80,18 @@ class AsyncUsageResource:
74
80
  self._client = client
75
81
 
76
82
  async def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
77
- """Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
83
+ """Record one event. Returns immediately (``processed: False``); totals materialise moments later.
84
+
85
+ The customer's cached entitlement answers go stale.
86
+ """
78
87
  result: UsageEventResponse = await self._client.request(record_spec(body, idempotency_key))
88
+ expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
79
89
  return result
80
90
 
81
91
  async def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
82
92
  """Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
83
93
  result: list[UsageEventResponse] = await self._client.request(record_batch_spec(events, idempotency_key))
94
+ expire_entitlements(self._client.entitlement_cache, [event["customer_external_id"] for event in events])
84
95
  return result
85
96
 
86
97
  async def event(self, event_id: str) -> UsageEventResponse:
@@ -1,66 +0,0 @@
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; a metered row answers for the next unit, as ``check()`` would.
39
-
40
- Empty ``entitlements`` without a subscription.
41
- """
42
- result: EntitlementsResponse = self._client.request(get_spec(customer_id))
43
- return result
44
-
45
- def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
46
- """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
47
- result: EntitlementResponse = self._client.request(check_spec(customer_id, feature, quantity))
48
- return result
49
-
50
-
51
- class AsyncEntitlementsResource:
52
- def __init__(self, client: AsyncMeterFlow) -> None:
53
- self._client = client
54
-
55
- async def get(self, customer_id: str) -> EntitlementsResponse:
56
- """Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
57
-
58
- Empty ``entitlements`` without a subscription.
59
- """
60
- result: EntitlementsResponse = await self._client.request(get_spec(customer_id))
61
- return result
62
-
63
- async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
64
- """May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
65
- result: EntitlementResponse = await self._client.request(check_spec(customer_id, feature, quantity))
66
- return result
File without changes
File without changes