usethatapp 2.1.0__tar.gz → 2.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.
Files changed (28) hide show
  1. {usethatapp-2.1.0 → usethatapp-2.2.0}/CHANGELOG.md +108 -0
  2. {usethatapp-2.1.0/usethatapp.egg-info → usethatapp-2.2.0}/PKG-INFO +48 -11
  3. {usethatapp-2.1.0 → usethatapp-2.2.0}/README.md +47 -10
  4. {usethatapp-2.1.0 → usethatapp-2.2.0}/pyproject.toml +1 -1
  5. {usethatapp-2.1.0 → usethatapp-2.2.0}/tests/conftest.py +7 -2
  6. {usethatapp-2.1.0 → usethatapp-2.2.0}/tests/test_client.py +200 -0
  7. usethatapp-2.2.0/tests/test_config.py +177 -0
  8. usethatapp-2.2.0/tests/test_license_keys.py +196 -0
  9. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp/__init__.py +45 -2
  10. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp/client.py +340 -32
  11. usethatapp-2.2.0/usethatapp/config.py +322 -0
  12. usethatapp-2.2.0/usethatapp/errors.py +110 -0
  13. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp/types.py +32 -4
  14. {usethatapp-2.1.0 → usethatapp-2.2.0/usethatapp.egg-info}/PKG-INFO +48 -11
  15. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp.egg-info/SOURCES.txt +1 -0
  16. usethatapp-2.1.0/tests/test_config.py +0 -59
  17. usethatapp-2.1.0/usethatapp/config.py +0 -175
  18. usethatapp-2.1.0/usethatapp/errors.py +0 -59
  19. {usethatapp-2.1.0 → usethatapp-2.2.0}/LICENSE +0 -0
  20. {usethatapp-2.1.0 → usethatapp-2.2.0}/MANIFEST.in +0 -0
  21. {usethatapp-2.1.0 → usethatapp-2.2.0}/setup.cfg +0 -0
  22. {usethatapp-2.1.0 → usethatapp-2.2.0}/tests/__init__.py +0 -0
  23. {usethatapp-2.1.0 → usethatapp-2.2.0}/tests/test_purchase.py +0 -0
  24. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp/discovery.py +0 -0
  25. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp/py.typed +0 -0
  26. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp.egg-info/dependency_links.txt +0 -0
  27. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp.egg-info/requires.txt +0 -0
  28. {usethatapp-2.1.0 → usethatapp-2.2.0}/usethatapp.egg-info/top_level.txt +0 -0
@@ -4,6 +4,114 @@ All notable changes to this project are documented in this file. This project ad
4
4
  [Semantic Versioning](https://semver.org/) and follows a clear, machine- and human-readable
5
5
  format inspired by "Keep a Changelog".
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [2.2.0] - 2026-08-25
10
+
11
+ ### Fixed (pre-release code review)
12
+
13
+ - The nine annotations the pre-release commits left untyped (most
14
+ visibly `UtaServerError.__init__`'s `retry_after`, which a
15
+ downstream strict type checker saw as `Any`) are restored —
16
+ `mypy --strict` is green again, as `py.typed` promises.
17
+
18
+ - `Entitlement.product_id` now carries the same
19
+ fall-back-to-`product_public_id` parsing as `LicenseState.product_id`,
20
+ so the alias contract ("gate on either field") holds in both
21
+ verification modes — previously only the License Key API parser had
22
+ the fallback.
23
+ - `configure(client_secret_path=…)` now outranks an environment
24
+ `UTA_CLIENT_SECRET`: secret-pair precedence is decided per layer
25
+ (overrides → Django settings → environment), not per key.
26
+ - `configure()` rejects wrong-typed values with a `UtaConfigError`
27
+ naming the option (e.g. a scopes *list* gets "join scope names with
28
+ spaces") instead of silently `str()`-coercing them into corrupt
29
+ values that surfaced later as opaque OAuth errors.
30
+ - A malformed `Retry-After` header containing Unicode digit-property
31
+ characters (e.g. `²`) no longer escapes as an uncaught `ValueError`
32
+ from a 429 — it degrades to `retry_after=None`.
33
+ - `userinfo()` and the token endpoint now speak the same 429/5xx
34
+ contract as every other endpoint: 429 raises retriable
35
+ `UtaServerError` with `retry_after` (a throttled token refresh is no
36
+ longer a `UtaTokenError`, which read as "log the user out"), and
37
+ their error messages cap quoted bodies like everywhere else.
38
+ - The ~200-character error-body cap now applies to JSON bodies too —
39
+ a multi-kilobyte JSON error (DRF validation map, debug-mode 500) was
40
+ previously quoted whole — and the truncation suffix reports the
41
+ amount actually cut.
42
+ - `timeout=0.0` (a fail-fast probe, expressible since the `float`
43
+ widening) is respected instead of being replaced by the configured
44
+ default in `get_entitlement`, `get_entitlement_async`, and the
45
+ License Key API calls.
46
+ - The `UtaServiceNotEnabledError` message points at the manage page on
47
+ `https://www.usethatapp.com` — not the resolved `api_url`, which a
48
+ `configure(api_url=…)` override can aim at a host with no manage UI.
49
+ - README: the error table names the add-on by its current public name
50
+ (Hosted sign-in), documents the 429 → `UtaServerError` +
51
+ `retry_after` mapping, and the `product_id` guidance matches the
52
+ post-cutover alias contract everywhere.
53
+
54
+ ### Added
55
+
56
+ - `LicenseState.product_id` — an equal-valued alias of
57
+ `product_public_id` (the platform's post-cutover `prod_…` pair, the
58
+ same contract as the entitlement endpoint), with a parser fallback
59
+ against servers that predate the cutover.
60
+
61
+ - **License Key API** (server-side, bring-your-own-auth MoR
62
+ verification): `validate_license_key(key)`, `get_order(ref)` for the
63
+ `uta_order` checkout handback, and `regenerate_license_key(license_id)`
64
+ as the compromise kill switch — all authenticated with your app's
65
+ client credentials (`UTA_CLIENT_SECRET` required; `UTA_REDIRECT_URI`
66
+ is no longer needed unless you use the OIDC login flow). New
67
+ `LicenseState` dataclass and typed errors: `UtaNotFoundError` (with
68
+ `.code`), `UtaOrderProcessingError`, `UtaLicenseCanceledError`.
69
+ - `config.load()` no longer requires `UTA_REDIRECT_URI`; `begin_login()`
70
+ raises `UtaConfigError` if it is missing — keys-mode integrations
71
+ never redirect a browser.
72
+
73
+ - `UtaServiceNotEnabledError` (subclass of `UtaPermissionError`), raised
74
+ when the entitlement endpoint returns `403 service_not_enabled`: the
75
+ app's developer has not enabled the Hosted sign-in add-on. The
76
+ previous behavior mislabeled this case as a missing `entitlements`
77
+ scope; the new error says the actual fix (enable the add-on on the
78
+ app's manage page). Existing `except UtaPermissionError` blocks catch
79
+ it unchanged.
80
+
81
+ - In-code configuration: `configure(api_url=…, issuer=…, …)`,
82
+ `load_config()`, and `reset_config()` are now exported from the
83
+ package root, alongside `UtaConfig` and the `DEFAULT_API_URL` /
84
+ `DEFAULT_ISSUER` / `DEFAULT_SCOPES` constants — parity with the
85
+ JavaScript SDK's `configure()` / `loadConfig()` / `resetConfig()`.
86
+ Overrides win over Django settings and environment variables, and a
87
+ non-production `api_url` can now be corrected at runtime instead of
88
+ only through the process environment.
89
+
90
+ - `UtaServerError.retry_after` — the server's `Retry-After` header in
91
+ seconds on a 429 (`None` otherwise), so backoff loops can use the
92
+ interval the platform actually sent instead of guessing. Matches the
93
+ JavaScript SDK's `retryAfter`.
94
+
95
+ ### Fixed
96
+
97
+ - `UtaNotFoundError`, `UtaOrderProcessingError`, and
98
+ `UtaLicenseCanceledError` are now exported from the package root —
99
+ they were defined and documented but not importable as
100
+ `from usethatapp import …`.
101
+ - The `UtaServiceNotEnabledError` message named the add-on by its
102
+ retired public name ("Auth & Entitlement") and hardcoded
103
+ `usethatapp.com`; it now says "Hosted sign-in" and points at the
104
+ configured `UTA_API_URL` host.
105
+ - A `429` from the entitlement endpoint now raises `UtaServerError`
106
+ (the retry-with-backoff class, matching the License Key API and the
107
+ documented error table) instead of the base `UtaError`.
108
+ - Non-JSON error bodies (HTML error pages) are collapsed and capped at
109
+ ~200 characters when quoted into exception messages, instead of
110
+ inlining the entire page.
111
+ - `get_entitlement(timeout=)` and `get_entitlement_async(timeout=)` are
112
+ typed `Optional[float]`, matching every other function (they were
113
+ `Optional[int]`, failing strict type checks on e.g. `timeout=2.5`).
114
+
7
115
  ## [2.1.0] - 2026-08-11
8
116
 
9
117
  Thin, dependency-free support for selling your app from your own website
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usethatapp
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries.
5
5
  Author-email: UseThatApp <support@usethatapp.com>
6
6
  License-Expression: MIT
@@ -112,7 +112,9 @@ from usethatapp import (
112
112
  AppPrices, # client_id, app_name, has_free_tier, prices
113
113
  # errors:
114
114
  UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
115
- UtaTokenError, UtaPermissionError, UtaServerError,
115
+ UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
116
+ UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
117
+ UtaServerError,
116
118
  )
117
119
  ```
118
120
 
@@ -152,14 +154,48 @@ are documentation only — nothing framework-specific ships in the package.
152
154
 
153
155
  `get_entitlement` maps status codes to typed exceptions:
154
156
 
155
- | Status | Exception | Meaning |
156
- |--------|----------------------|-----------------------------------------------|
157
- | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
158
- | 403 | `UtaPermissionError` | Token lacks the `entitlements` scope. |
159
- | 400 | `UtaError` | Client not linked to an app (misconfig). |
160
- | 5xx | `UtaServerError` | Retriable with backoff. |
157
+ | Status | Exception | Meaning |
158
+ |--------|-----------------------------------------------|-----------------------------------------------|
159
+ | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
160
+ | 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
161
+ | 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
162
+ | 400 | `UtaError` | Client not linked to an app (misconfig). |
163
+ | 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
164
+ | 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
161
165
 
162
166
  All inherit from `UtaError` — catch that for a single `except` clause.
167
+ `UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
168
+ `except UtaPermissionError` blocks keep working unchanged.
169
+
170
+ ## License Key API — bring your own auth
171
+
172
+ If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
173
+ purely as merchant of record, skip the OIDC flow entirely and verify
174
+ purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
175
+ (`UTA_REDIRECT_URI` is not needed for this mode).
176
+
177
+ ```python
178
+ from usethatapp import get_order, regenerate_license_key, validate_license_key
179
+
180
+ # 1) The buyer returns from checkout with ?uta_order=... — exchange it
181
+ # for their key and link it to YOUR signed-in user. No email matching.
182
+ order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
183
+ your_db.save(user=current_user, license_key=order.license_key,
184
+ license_id=order.license_id)
185
+
186
+ # 2) Any time you need the truth (app start, feature gate, cron):
187
+ state = validate_license_key(stored_key) # LicenseState
188
+ if state.entitled: ... # cache against state.period_end
189
+ # canceled purchases return entitled=False, status="canceled" — an
190
+ # answer, not an exception. UtaNotFoundError means a key that was
191
+ # never issued for your app.
192
+
193
+ # 3) Compromised key? Rotate it — the old key dies instantly:
194
+ state = regenerate_license_key(license_id) # state.license_key is the new key
195
+ ```
196
+
197
+ Buyers can always re-view their key on their UseThatApp management
198
+ page, so "I lost my key" needs no support ticket and no rotation.
163
199
 
164
200
  ## Purchase links & pricing
165
201
 
@@ -200,9 +236,10 @@ for price in pricing.prices:
200
236
  `Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
201
237
  `decimal.Decimal` if you need arithmetic. `Price.product_id` is the
202
238
  opaque `prod_…` identifier to gate features on after purchase — compare
203
- it against **`Entitlement.product_public_id`** (`Entitlement.product_id`
204
- still carries the legacy UUID until the platform's identifier cutover,
205
- after which both fields carry the same `prod_…` value).
239
+ it against `Entitlement.product_id` or `Entitlement.product_public_id`:
240
+ since the platform's identifier cutover they are a permanent
241
+ equal-valued alias pair carrying the same `prod_…` value, so gate on
242
+ either.
206
243
 
207
244
  Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
208
245
  are handled for you). Purchases are currently **US-only**. External
@@ -79,7 +79,9 @@ from usethatapp import (
79
79
  AppPrices, # client_id, app_name, has_free_tier, prices
80
80
  # errors:
81
81
  UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
82
- UtaTokenError, UtaPermissionError, UtaServerError,
82
+ UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
83
+ UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
84
+ UtaServerError,
83
85
  )
84
86
  ```
85
87
 
@@ -119,14 +121,48 @@ are documentation only — nothing framework-specific ships in the package.
119
121
 
120
122
  `get_entitlement` maps status codes to typed exceptions:
121
123
 
122
- | Status | Exception | Meaning |
123
- |--------|----------------------|-----------------------------------------------|
124
- | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
125
- | 403 | `UtaPermissionError` | Token lacks the `entitlements` scope. |
126
- | 400 | `UtaError` | Client not linked to an app (misconfig). |
127
- | 5xx | `UtaServerError` | Retriable with backoff. |
124
+ | Status | Exception | Meaning |
125
+ |--------|-----------------------------------------------|-----------------------------------------------|
126
+ | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
127
+ | 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
128
+ | 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
129
+ | 400 | `UtaError` | Client not linked to an app (misconfig). |
130
+ | 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
131
+ | 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
128
132
 
129
133
  All inherit from `UtaError` — catch that for a single `except` clause.
134
+ `UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
135
+ `except UtaPermissionError` blocks keep working unchanged.
136
+
137
+ ## License Key API — bring your own auth
138
+
139
+ If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
140
+ purely as merchant of record, skip the OIDC flow entirely and verify
141
+ purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
142
+ (`UTA_REDIRECT_URI` is not needed for this mode).
143
+
144
+ ```python
145
+ from usethatapp import get_order, regenerate_license_key, validate_license_key
146
+
147
+ # 1) The buyer returns from checkout with ?uta_order=... — exchange it
148
+ # for their key and link it to YOUR signed-in user. No email matching.
149
+ order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
150
+ your_db.save(user=current_user, license_key=order.license_key,
151
+ license_id=order.license_id)
152
+
153
+ # 2) Any time you need the truth (app start, feature gate, cron):
154
+ state = validate_license_key(stored_key) # LicenseState
155
+ if state.entitled: ... # cache against state.period_end
156
+ # canceled purchases return entitled=False, status="canceled" — an
157
+ # answer, not an exception. UtaNotFoundError means a key that was
158
+ # never issued for your app.
159
+
160
+ # 3) Compromised key? Rotate it — the old key dies instantly:
161
+ state = regenerate_license_key(license_id) # state.license_key is the new key
162
+ ```
163
+
164
+ Buyers can always re-view their key on their UseThatApp management
165
+ page, so "I lost my key" needs no support ticket and no rotation.
130
166
 
131
167
  ## Purchase links & pricing
132
168
 
@@ -167,9 +203,10 @@ for price in pricing.prices:
167
203
  `Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
168
204
  `decimal.Decimal` if you need arithmetic. `Price.product_id` is the
169
205
  opaque `prod_…` identifier to gate features on after purchase — compare
170
- it against **`Entitlement.product_public_id`** (`Entitlement.product_id`
171
- still carries the legacy UUID until the platform's identifier cutover,
172
- after which both fields carry the same `prod_…` value).
206
+ it against `Entitlement.product_id` or `Entitlement.product_public_id`:
207
+ since the platform's identifier cutover they are a permanent
208
+ equal-valued alias pair carrying the same `prod_…` value, so gate on
209
+ either.
173
210
 
174
211
  Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
175
212
  are handled for you). Purchases are currently **US-only**. External
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "usethatapp"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  authors = [{ name = "UseThatApp", email = "support@usethatapp.com"}]
@@ -71,10 +71,15 @@ def configure_env(monkeypatch):
71
71
  monkeypatch.setenv("UTA_CLOCK_SKEW_SECONDS", "60")
72
72
  # Drop any inherited secret-path var so tests are hermetic.
73
73
  monkeypatch.delenv("UTA_CLIENT_SECRET_PATH", raising=False)
74
- uta_config.reset_cache()
74
+ # reset_config(), not reset_cache(): the process-global _overrides
75
+ # dict must be cleared too, or one test's configure() call leaks
76
+ # into every later test — and since overrides outrank monkeypatched
77
+ # env vars, the leak surfaces as unrelated respx "no route matched"
78
+ # failures (code review finding 7).
79
+ uta_config.reset_config()
75
80
  uta_discovery.reset_cache()
76
81
  yield
77
- uta_config.reset_cache()
82
+ uta_config.reset_config()
78
83
  uta_discovery.reset_cache()
79
84
 
80
85
 
@@ -6,6 +6,8 @@ from urllib.parse import parse_qs, urlparse
6
6
  import httpx
7
7
  import pytest
8
8
 
9
+ import json
10
+
9
11
  from usethatapp import (
10
12
  begin_login,
11
13
  complete_login,
@@ -19,6 +21,7 @@ from usethatapp.errors import (
19
21
  UtaAuthError,
20
22
  UtaError,
21
23
  UtaPermissionError,
24
+ UtaServiceNotEnabledError,
22
25
  UtaServerError,
23
26
  UtaTokenError,
24
27
  )
@@ -172,6 +175,44 @@ def test_get_entitlement_status_mapping(oidc_routes, status, exc):
172
175
  get_entitlement("at-123")
173
176
 
174
177
 
178
+ def test_get_entitlement_service_not_enabled(oidc_routes):
179
+ """A 403 whose body says service_not_enabled is the developer's to
180
+ fix (enable the add-on) — it must NOT be reported as a scope
181
+ problem, but must stay catchable as UtaPermissionError."""
182
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
183
+ return_value=httpx.Response(403, json={
184
+ "error": "service_not_enabled",
185
+ "error_description": "The developer has not enabled the "
186
+ "entitlement service for this app.",
187
+ })
188
+ )
189
+ with pytest.raises(UtaServiceNotEnabledError, match="Hosted sign-in") as exc_info:
190
+ get_entitlement("at-123")
191
+ assert issubclass(UtaServiceNotEnabledError, UtaPermissionError)
192
+ # Glassbox F-10: the message names the CURRENT public feature name.
193
+ # Code review finding 10 (reversing F-10's host half): the manage
194
+ # hub lives on the production dashboard host — the configured
195
+ # api_url may be a dev stack or gateway with no manage UI, so the
196
+ # message must NOT point there.
197
+ message = str(exc_info.value)
198
+ assert "Auth & Entitlement" not in message
199
+ assert "https://www.usethatapp.com" in message
200
+ assert API_URL not in message
201
+
202
+
203
+ def test_get_entitlement_scope_403_still_permission_error(oidc_routes):
204
+ """The pre-existing 403 (insufficient_scope) keeps its exact class —
205
+ not the new subclass — so callers can tell the two apart."""
206
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
207
+ return_value=httpx.Response(403, json={
208
+ "error": "insufficient_scope", "scope": "entitlements",
209
+ })
210
+ )
211
+ with pytest.raises(UtaPermissionError) as excinfo:
212
+ get_entitlement("at-123")
213
+ assert type(excinfo.value) is UtaPermissionError
214
+
215
+
175
216
  @pytest.mark.asyncio
176
217
  async def test_get_entitlement_async(oidc_routes):
177
218
  oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
@@ -251,3 +292,162 @@ def test_logout_url(oidc_routes):
251
292
  assert q["id_token_hint"] == ["idt"]
252
293
  assert q["post_logout_redirect_uri"] == ["https://app.test.example/bye"]
253
294
  assert q["client_id"] == [CLIENT_ID]
295
+
296
+
297
+ def test_get_entitlement_429_maps_to_server_error(oidc_routes):
298
+ """Glassbox F-04: a throttle answer is transient — it must land in
299
+ UtaServerError (the retry-with-backoff class), not the base class."""
300
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
301
+ return_value=httpx.Response(
302
+ 429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
303
+ )
304
+ )
305
+ with pytest.raises(UtaServerError, match="rate limited"):
306
+ get_entitlement("at-123")
307
+
308
+
309
+ def test_snippet_caps_every_body_shape():
310
+ """Glassbox F-11 + code review finding 5: ALL bodies are collapsed
311
+ and capped — JSON gets no exemption, because a DRF validation map or
312
+ debug-mode JSON 500 can carry a multi-kilobyte traceback (the
313
+ log-flooding the cap exists to stop). Short bodies pass whole."""
314
+ from usethatapp.client import _snippet
315
+
316
+ json_body = '{"error": "unknown_key"}'
317
+ assert _snippet(json_body) == json_body
318
+
319
+ html = "<!DOCTYPE html>\n<html>\n" + ("<p>filler</p>\n" * 200) + "</html>"
320
+ out = _snippet(html)
321
+ assert len(out) < 260
322
+ assert "\n" not in out
323
+ assert "truncated" in out
324
+
325
+ big_json = json.dumps({"detail": "Traceback (most recent call last)" * 200})
326
+ out = _snippet(big_json)
327
+ assert len(out) < 260
328
+ assert "truncated" in out
329
+
330
+
331
+ def test_non_json_error_body_is_not_inlined_whole(oidc_routes):
332
+ html_page = "<!DOCTYPE html><html>" + "x" * 5000 + "</html>"
333
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
334
+ return_value=httpx.Response(500, text=html_page)
335
+ )
336
+ with pytest.raises(UtaServerError) as exc_info:
337
+ get_entitlement("at-123")
338
+ assert len(str(exc_info.value)) < 400
339
+
340
+
341
+ def test_429_surfaces_retry_after_on_the_exception(oidc_routes):
342
+ """Quiver J-05: the server's backoff hint must reach the caller —
343
+ documented, sent, and previously discarded one layer below them."""
344
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
345
+ return_value=httpx.Response(
346
+ 429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
347
+ )
348
+ )
349
+ with pytest.raises(UtaServerError) as exc_info:
350
+ get_entitlement("at-123")
351
+ assert exc_info.value.retry_after == 60
352
+
353
+
354
+ def test_retry_after_is_none_when_absent(oidc_routes):
355
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
356
+ return_value=httpx.Response(503, text="unavailable")
357
+ )
358
+ with pytest.raises(UtaServerError) as exc_info:
359
+ get_entitlement("at-123")
360
+ assert exc_info.value.retry_after is None
361
+
362
+
363
+ def test_retry_after_http_date_form_is_none(oidc_routes):
364
+ # RFC 9110 allows an HTTP-date; we only surface the seconds form.
365
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
366
+ return_value=httpx.Response(
367
+ 429, json={"error": "rate_limited"},
368
+ headers={"Retry-After": "Sat, 23 Aug 2026 02:00:00 GMT"},
369
+ )
370
+ )
371
+ with pytest.raises(UtaServerError) as exc_info:
372
+ get_entitlement("at-123")
373
+ assert exc_info.value.retry_after is None
374
+
375
+
376
+ def test_entitlement_product_id_falls_back_to_public_id(oidc_routes):
377
+ """Code review finding 1: the alias promise in types.py ("gate on
378
+ either field") must hold against a server that predates the
379
+ identifier cutover — same fallback the LicenseState parser has."""
380
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
381
+ return_value=httpx.Response(200, json={
382
+ "entitled": True,
383
+ "version": "Pro",
384
+ "status": "active",
385
+ "product_public_id": "prod_abc123",
386
+ # no product_id field at all
387
+ })
388
+ )
389
+ ent = get_entitlement("at-123")
390
+ assert ent.product_id == "prod_abc123"
391
+ assert ent.product_id == ent.product_public_id
392
+
393
+
394
+ def test_unicode_digit_retry_after_does_not_crash(oidc_routes):
395
+ """Code review finding 3 (reproduced): '²'.isdigit() is True but
396
+ int('²') raises — a malformed Retry-After must degrade to None, not
397
+ turn a 429 into an uncaught ValueError."""
398
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
399
+ # As the wire delivers it: the latin-1 byte 0xB2, which httpx
400
+ # decodes to '²' on read.
401
+ return_value=httpx.Response(
402
+ 429, text="slow down", headers=[(b"Retry-After", b"\xb2")]
403
+ )
404
+ )
405
+ with pytest.raises(UtaServerError) as exc_info:
406
+ get_entitlement("at-123")
407
+ assert exc_info.value.retry_after is None
408
+
409
+
410
+ def test_userinfo_429_is_retriable_with_retry_after(oidc_routes):
411
+ """Code review finding 4: the backoff loop this release invites must
412
+ work when the same rate limiter throttles userinfo during login."""
413
+ oidc_routes.get(METADATA["userinfo_endpoint"]).mock(
414
+ return_value=httpx.Response(
415
+ 429, text="rate limited", headers={"Retry-After": "30"}
416
+ )
417
+ )
418
+ with pytest.raises(UtaServerError) as exc_info:
419
+ userinfo("at-123")
420
+ assert exc_info.value.retry_after == 30
421
+
422
+
423
+ def test_token_endpoint_429_is_not_a_credential_failure(oidc_routes):
424
+ """Code review finding 4: a throttled token refresh is retriable
425
+ server pushback — surfacing it as UtaTokenError makes callers log
426
+ the user out instead of backing off."""
427
+ oidc_routes.post(METADATA["token_endpoint"]).mock(
428
+ return_value=httpx.Response(
429
+ 429, text="rate limited", headers={"Retry-After": "15"}
430
+ )
431
+ )
432
+ with pytest.raises(UtaServerError) as exc_info:
433
+ refresh("rt-123")
434
+ assert exc_info.value.retry_after == 15
435
+
436
+
437
+ def test_timeout_zero_is_respected(oidc_routes, monkeypatch):
438
+ """Code review finding 8: timeout=0.0 — expressible since the float
439
+ widening — is a fail-fast probe, not falsy noise to be replaced by
440
+ the configured default."""
441
+ seen = {}
442
+ real_get = httpx.get
443
+
444
+ def spying_get(url, **kwargs):
445
+ seen["timeout"] = kwargs.get("timeout")
446
+ return real_get(url, **kwargs)
447
+
448
+ monkeypatch.setattr(httpx, "get", spying_get)
449
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
450
+ return_value=httpx.Response(200, json={"entitled": False})
451
+ )
452
+ get_entitlement("at-123", timeout=0.0)
453
+ assert seen["timeout"] == 0.0