usethatapp 2.1.0__tar.gz → 2.3.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.3.0}/CHANGELOG.md +118 -0
  2. {usethatapp-2.1.0/usethatapp.egg-info → usethatapp-2.3.0}/PKG-INFO +51 -12
  3. {usethatapp-2.1.0 → usethatapp-2.3.0}/README.md +50 -11
  4. {usethatapp-2.1.0 → usethatapp-2.3.0}/pyproject.toml +1 -1
  5. {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/conftest.py +7 -2
  6. {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/test_client.py +214 -0
  7. usethatapp-2.3.0/tests/test_config.py +177 -0
  8. usethatapp-2.3.0/tests/test_license_keys.py +197 -0
  9. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/__init__.py +45 -2
  10. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/client.py +342 -32
  11. usethatapp-2.3.0/usethatapp/config.py +322 -0
  12. usethatapp-2.3.0/usethatapp/errors.py +110 -0
  13. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/types.py +45 -4
  14. {usethatapp-2.1.0 → usethatapp-2.3.0/usethatapp.egg-info}/PKG-INFO +51 -12
  15. {usethatapp-2.1.0 → usethatapp-2.3.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.3.0}/LICENSE +0 -0
  20. {usethatapp-2.1.0 → usethatapp-2.3.0}/MANIFEST.in +0 -0
  21. {usethatapp-2.1.0 → usethatapp-2.3.0}/setup.cfg +0 -0
  22. {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/__init__.py +0 -0
  23. {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/test_purchase.py +0 -0
  24. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/discovery.py +0 -0
  25. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/py.typed +0 -0
  26. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/dependency_links.txt +0 -0
  27. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/requires.txt +0 -0
  28. {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/top_level.txt +0 -0
@@ -4,6 +4,124 @@ 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.3.0] - 2026-10-29
10
+
11
+ ### Added
12
+
13
+ - `Entitlement.is_trial` and `LicenseState.is_trial`: true while the user is in a free
14
+ trial of a paid plan (entitled, nothing charged yet; `period_end` is the conversion
15
+ date). Explicit so integrations never string-match `status == "trialing"`. Both are
16
+ trailing fields with a `False` default, so positional construction and servers that
17
+ predate the field keep working.
18
+
19
+ ## [2.2.0] - 2026-08-25
20
+
21
+ ### Fixed (pre-release code review)
22
+
23
+ - The nine annotations the pre-release commits left untyped (most
24
+ visibly `UtaServerError.__init__`'s `retry_after`, which a
25
+ downstream strict type checker saw as `Any`) are restored —
26
+ `mypy --strict` is green again, as `py.typed` promises.
27
+
28
+ - `Entitlement.product_id` now carries the same
29
+ fall-back-to-`product_public_id` parsing as `LicenseState.product_id`,
30
+ so the alias contract ("gate on either field") holds in both
31
+ verification modes — previously only the License Key API parser had
32
+ the fallback.
33
+ - `configure(client_secret_path=…)` now outranks an environment
34
+ `UTA_CLIENT_SECRET`: secret-pair precedence is decided per layer
35
+ (overrides → Django settings → environment), not per key.
36
+ - `configure()` rejects wrong-typed values with a `UtaConfigError`
37
+ naming the option (e.g. a scopes *list* gets "join scope names with
38
+ spaces") instead of silently `str()`-coercing them into corrupt
39
+ values that surfaced later as opaque OAuth errors.
40
+ - A malformed `Retry-After` header containing Unicode digit-property
41
+ characters (e.g. `²`) no longer escapes as an uncaught `ValueError`
42
+ from a 429 — it degrades to `retry_after=None`.
43
+ - `userinfo()` and the token endpoint now speak the same 429/5xx
44
+ contract as every other endpoint: 429 raises retriable
45
+ `UtaServerError` with `retry_after` (a throttled token refresh is no
46
+ longer a `UtaTokenError`, which read as "log the user out"), and
47
+ their error messages cap quoted bodies like everywhere else.
48
+ - The ~200-character error-body cap now applies to JSON bodies too —
49
+ a multi-kilobyte JSON error (DRF validation map, debug-mode 500) was
50
+ previously quoted whole — and the truncation suffix reports the
51
+ amount actually cut.
52
+ - `timeout=0.0` (a fail-fast probe, expressible since the `float`
53
+ widening) is respected instead of being replaced by the configured
54
+ default in `get_entitlement`, `get_entitlement_async`, and the
55
+ License Key API calls.
56
+ - The `UtaServiceNotEnabledError` message points at the manage page on
57
+ `https://www.usethatapp.com` — not the resolved `api_url`, which a
58
+ `configure(api_url=…)` override can aim at a host with no manage UI.
59
+ - README: the error table names the add-on by its current public name
60
+ (Hosted sign-in), documents the 429 → `UtaServerError` +
61
+ `retry_after` mapping, and the `product_id` guidance matches the
62
+ post-cutover alias contract everywhere.
63
+
64
+ ### Added
65
+
66
+ - `LicenseState.product_id` — an equal-valued alias of
67
+ `product_public_id` (the platform's post-cutover `prod_…` pair, the
68
+ same contract as the entitlement endpoint), with a parser fallback
69
+ against servers that predate the cutover.
70
+
71
+ - **License Key API** (server-side, bring-your-own-auth MoR
72
+ verification): `validate_license_key(key)`, `get_order(ref)` for the
73
+ `uta_order` checkout handback, and `regenerate_license_key(license_id)`
74
+ as the compromise kill switch — all authenticated with your app's
75
+ client credentials (`UTA_CLIENT_SECRET` required; `UTA_REDIRECT_URI`
76
+ is no longer needed unless you use the OIDC login flow). New
77
+ `LicenseState` dataclass and typed errors: `UtaNotFoundError` (with
78
+ `.code`), `UtaOrderProcessingError`, `UtaLicenseCanceledError`.
79
+ - `config.load()` no longer requires `UTA_REDIRECT_URI`; `begin_login()`
80
+ raises `UtaConfigError` if it is missing — keys-mode integrations
81
+ never redirect a browser.
82
+
83
+ - `UtaServiceNotEnabledError` (subclass of `UtaPermissionError`), raised
84
+ when the entitlement endpoint returns `403 service_not_enabled`: the
85
+ app's developer has not enabled the Hosted sign-in add-on. The
86
+ previous behavior mislabeled this case as a missing `entitlements`
87
+ scope; the new error says the actual fix (enable the add-on on the
88
+ app's manage page). Existing `except UtaPermissionError` blocks catch
89
+ it unchanged.
90
+
91
+ - In-code configuration: `configure(api_url=…, issuer=…, …)`,
92
+ `load_config()`, and `reset_config()` are now exported from the
93
+ package root, alongside `UtaConfig` and the `DEFAULT_API_URL` /
94
+ `DEFAULT_ISSUER` / `DEFAULT_SCOPES` constants — parity with the
95
+ JavaScript SDK's `configure()` / `loadConfig()` / `resetConfig()`.
96
+ Overrides win over Django settings and environment variables, and a
97
+ non-production `api_url` can now be corrected at runtime instead of
98
+ only through the process environment.
99
+
100
+ - `UtaServerError.retry_after` — the server's `Retry-After` header in
101
+ seconds on a 429 (`None` otherwise), so backoff loops can use the
102
+ interval the platform actually sent instead of guessing. Matches the
103
+ JavaScript SDK's `retryAfter`.
104
+
105
+ ### Fixed
106
+
107
+ - `UtaNotFoundError`, `UtaOrderProcessingError`, and
108
+ `UtaLicenseCanceledError` are now exported from the package root —
109
+ they were defined and documented but not importable as
110
+ `from usethatapp import …`.
111
+ - The `UtaServiceNotEnabledError` message named the add-on by its
112
+ retired public name ("Auth & Entitlement") and hardcoded
113
+ `usethatapp.com`; it now says "Hosted sign-in" and points at the
114
+ configured `UTA_API_URL` host.
115
+ - A `429` from the entitlement endpoint now raises `UtaServerError`
116
+ (the retry-with-backoff class, matching the License Key API and the
117
+ documented error table) instead of the base `UtaError`.
118
+ - Non-JSON error bodies (HTML error pages) are collapsed and capped at
119
+ ~200 characters when quoted into exception messages, instead of
120
+ inlining the entire page.
121
+ - `get_entitlement(timeout=)` and `get_entitlement_async(timeout=)` are
122
+ typed `Optional[float]`, matching every other function (they were
123
+ `Optional[int]`, failing strict type checks on e.g. `timeout=2.5`).
124
+
7
125
  ## [2.1.0] - 2026-08-11
8
126
 
9
127
  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.3.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
@@ -106,13 +106,15 @@ from usethatapp import (
106
106
  get_prices, # () -> AppPrices (anonymous public API)
107
107
  get_prices_async,
108
108
  UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
109
- Entitlement, # entitled, version, product_id, product_public_id, status, ...
109
+ Entitlement, # entitled, version, product_id, product_public_id, status, is_trial, ...
110
110
  AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
111
111
  Price, # public_id, product_id, product_name, amount, currency, ...
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,50 @@ 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
+ # state.is_trial: in a free trial — entitled, nothing charged yet, and
190
+ # period_end is the conversion date. Show a banner; gate nothing extra.
191
+ # canceled purchases return entitled=False, status="canceled" — an
192
+ # answer, not an exception. UtaNotFoundError means a key that was
193
+ # never issued for your app.
194
+
195
+ # 3) Compromised key? Rotate it — the old key dies instantly:
196
+ state = regenerate_license_key(license_id) # state.license_key is the new key
197
+ ```
198
+
199
+ Buyers can always re-view their key on their UseThatApp management
200
+ page, so "I lost my key" needs no support ticket and no rotation.
163
201
 
164
202
  ## Purchase links & pricing
165
203
 
@@ -200,9 +238,10 @@ for price in pricing.prices:
200
238
  `Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
201
239
  `decimal.Decimal` if you need arithmetic. `Price.product_id` is the
202
240
  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).
241
+ it against `Entitlement.product_id` or `Entitlement.product_public_id`:
242
+ since the platform's identifier cutover they are a permanent
243
+ equal-valued alias pair carrying the same `prod_…` value, so gate on
244
+ either.
206
245
 
207
246
  Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
208
247
  are handled for you). Purchases are currently **US-only**. External
@@ -73,13 +73,15 @@ from usethatapp import (
73
73
  get_prices, # () -> AppPrices (anonymous public API)
74
74
  get_prices_async,
75
75
  UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
76
- Entitlement, # entitled, version, product_id, product_public_id, status, ...
76
+ Entitlement, # entitled, version, product_id, product_public_id, status, is_trial, ...
77
77
  AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
78
78
  Price, # public_id, product_id, product_name, amount, currency, ...
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,50 @@ 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
+ # state.is_trial: in a free trial — entitled, nothing charged yet, and
157
+ # period_end is the conversion date. Show a banner; gate nothing extra.
158
+ # canceled purchases return entitled=False, status="canceled" — an
159
+ # answer, not an exception. UtaNotFoundError means a key that was
160
+ # never issued for your app.
161
+
162
+ # 3) Compromised key? Rotate it — the old key dies instantly:
163
+ state = regenerate_license_key(license_id) # state.license_key is the new key
164
+ ```
165
+
166
+ Buyers can always re-view their key on their UseThatApp management
167
+ page, so "I lost my key" needs no support ticket and no rotation.
130
168
 
131
169
  ## Purchase links & pricing
132
170
 
@@ -167,9 +205,10 @@ for price in pricing.prices:
167
205
  `Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
168
206
  `decimal.Decimal` if you need arithmetic. `Price.product_id` is the
169
207
  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).
208
+ it against `Entitlement.product_id` or `Entitlement.product_public_id`:
209
+ since the platform's identifier cutover they are a permanent
210
+ equal-valued alias pair carrying the same `prod_…` value, so gate on
211
+ either.
173
212
 
174
213
  Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
175
214
  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.3.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
  )
@@ -145,9 +148,23 @@ def test_get_entitlement_licensed(oidc_routes):
145
148
  assert ent.entitled and ent.version == "Pro" and ent.product_id == "p-1"
146
149
  assert ent.product_public_id == "prod_abc123"
147
150
  assert ent.status == "active" and ent.is_free is False
151
+ assert ent.is_trial is False
148
152
  assert ent.period_end == "2026-07-01"
149
153
 
150
154
 
155
+ def test_get_entitlement_trialing(oidc_routes):
156
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
157
+ return_value=httpx.Response(200, json={
158
+ "entitled": True, "version": "Pro", "product_id": "prod_abc",
159
+ "product_public_id": "prod_abc", "status": "trialing",
160
+ "is_free": False, "is_trial": True, "period_end": "2026-11-11",
161
+ })
162
+ )
163
+ ent = get_entitlement("at-123")
164
+ assert ent.entitled and ent.is_trial and ent.status == "trialing"
165
+ assert ent.period_end == "2026-11-11"
166
+
167
+
151
168
  def test_get_entitlement_free(oidc_routes):
152
169
  oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
153
170
  return_value=httpx.Response(200, json={
@@ -172,6 +189,44 @@ def test_get_entitlement_status_mapping(oidc_routes, status, exc):
172
189
  get_entitlement("at-123")
173
190
 
174
191
 
192
+ def test_get_entitlement_service_not_enabled(oidc_routes):
193
+ """A 403 whose body says service_not_enabled is the developer's to
194
+ fix (enable the add-on) — it must NOT be reported as a scope
195
+ problem, but must stay catchable as UtaPermissionError."""
196
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
197
+ return_value=httpx.Response(403, json={
198
+ "error": "service_not_enabled",
199
+ "error_description": "The developer has not enabled the "
200
+ "entitlement service for this app.",
201
+ })
202
+ )
203
+ with pytest.raises(UtaServiceNotEnabledError, match="Hosted sign-in") as exc_info:
204
+ get_entitlement("at-123")
205
+ assert issubclass(UtaServiceNotEnabledError, UtaPermissionError)
206
+ # Glassbox F-10: the message names the CURRENT public feature name.
207
+ # Code review finding 10 (reversing F-10's host half): the manage
208
+ # hub lives on the production dashboard host — the configured
209
+ # api_url may be a dev stack or gateway with no manage UI, so the
210
+ # message must NOT point there.
211
+ message = str(exc_info.value)
212
+ assert "Auth & Entitlement" not in message
213
+ assert "https://www.usethatapp.com" in message
214
+ assert API_URL not in message
215
+
216
+
217
+ def test_get_entitlement_scope_403_still_permission_error(oidc_routes):
218
+ """The pre-existing 403 (insufficient_scope) keeps its exact class —
219
+ not the new subclass — so callers can tell the two apart."""
220
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
221
+ return_value=httpx.Response(403, json={
222
+ "error": "insufficient_scope", "scope": "entitlements",
223
+ })
224
+ )
225
+ with pytest.raises(UtaPermissionError) as excinfo:
226
+ get_entitlement("at-123")
227
+ assert type(excinfo.value) is UtaPermissionError
228
+
229
+
175
230
  @pytest.mark.asyncio
176
231
  async def test_get_entitlement_async(oidc_routes):
177
232
  oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
@@ -251,3 +306,162 @@ def test_logout_url(oidc_routes):
251
306
  assert q["id_token_hint"] == ["idt"]
252
307
  assert q["post_logout_redirect_uri"] == ["https://app.test.example/bye"]
253
308
  assert q["client_id"] == [CLIENT_ID]
309
+
310
+
311
+ def test_get_entitlement_429_maps_to_server_error(oidc_routes):
312
+ """Glassbox F-04: a throttle answer is transient — it must land in
313
+ UtaServerError (the retry-with-backoff class), not the base class."""
314
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
315
+ return_value=httpx.Response(
316
+ 429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
317
+ )
318
+ )
319
+ with pytest.raises(UtaServerError, match="rate limited"):
320
+ get_entitlement("at-123")
321
+
322
+
323
+ def test_snippet_caps_every_body_shape():
324
+ """Glassbox F-11 + code review finding 5: ALL bodies are collapsed
325
+ and capped — JSON gets no exemption, because a DRF validation map or
326
+ debug-mode JSON 500 can carry a multi-kilobyte traceback (the
327
+ log-flooding the cap exists to stop). Short bodies pass whole."""
328
+ from usethatapp.client import _snippet
329
+
330
+ json_body = '{"error": "unknown_key"}'
331
+ assert _snippet(json_body) == json_body
332
+
333
+ html = "<!DOCTYPE html>\n<html>\n" + ("<p>filler</p>\n" * 200) + "</html>"
334
+ out = _snippet(html)
335
+ assert len(out) < 260
336
+ assert "\n" not in out
337
+ assert "truncated" in out
338
+
339
+ big_json = json.dumps({"detail": "Traceback (most recent call last)" * 200})
340
+ out = _snippet(big_json)
341
+ assert len(out) < 260
342
+ assert "truncated" in out
343
+
344
+
345
+ def test_non_json_error_body_is_not_inlined_whole(oidc_routes):
346
+ html_page = "<!DOCTYPE html><html>" + "x" * 5000 + "</html>"
347
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
348
+ return_value=httpx.Response(500, text=html_page)
349
+ )
350
+ with pytest.raises(UtaServerError) as exc_info:
351
+ get_entitlement("at-123")
352
+ assert len(str(exc_info.value)) < 400
353
+
354
+
355
+ def test_429_surfaces_retry_after_on_the_exception(oidc_routes):
356
+ """Quiver J-05: the server's backoff hint must reach the caller —
357
+ documented, sent, and previously discarded one layer below them."""
358
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
359
+ return_value=httpx.Response(
360
+ 429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
361
+ )
362
+ )
363
+ with pytest.raises(UtaServerError) as exc_info:
364
+ get_entitlement("at-123")
365
+ assert exc_info.value.retry_after == 60
366
+
367
+
368
+ def test_retry_after_is_none_when_absent(oidc_routes):
369
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
370
+ return_value=httpx.Response(503, text="unavailable")
371
+ )
372
+ with pytest.raises(UtaServerError) as exc_info:
373
+ get_entitlement("at-123")
374
+ assert exc_info.value.retry_after is None
375
+
376
+
377
+ def test_retry_after_http_date_form_is_none(oidc_routes):
378
+ # RFC 9110 allows an HTTP-date; we only surface the seconds form.
379
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
380
+ return_value=httpx.Response(
381
+ 429, json={"error": "rate_limited"},
382
+ headers={"Retry-After": "Sat, 23 Aug 2026 02:00:00 GMT"},
383
+ )
384
+ )
385
+ with pytest.raises(UtaServerError) as exc_info:
386
+ get_entitlement("at-123")
387
+ assert exc_info.value.retry_after is None
388
+
389
+
390
+ def test_entitlement_product_id_falls_back_to_public_id(oidc_routes):
391
+ """Code review finding 1: the alias promise in types.py ("gate on
392
+ either field") must hold against a server that predates the
393
+ identifier cutover — same fallback the LicenseState parser has."""
394
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
395
+ return_value=httpx.Response(200, json={
396
+ "entitled": True,
397
+ "version": "Pro",
398
+ "status": "active",
399
+ "product_public_id": "prod_abc123",
400
+ # no product_id field at all
401
+ })
402
+ )
403
+ ent = get_entitlement("at-123")
404
+ assert ent.product_id == "prod_abc123"
405
+ assert ent.product_id == ent.product_public_id
406
+
407
+
408
+ def test_unicode_digit_retry_after_does_not_crash(oidc_routes):
409
+ """Code review finding 3 (reproduced): '²'.isdigit() is True but
410
+ int('²') raises — a malformed Retry-After must degrade to None, not
411
+ turn a 429 into an uncaught ValueError."""
412
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
413
+ # As the wire delivers it: the latin-1 byte 0xB2, which httpx
414
+ # decodes to '²' on read.
415
+ return_value=httpx.Response(
416
+ 429, text="slow down", headers=[(b"Retry-After", b"\xb2")]
417
+ )
418
+ )
419
+ with pytest.raises(UtaServerError) as exc_info:
420
+ get_entitlement("at-123")
421
+ assert exc_info.value.retry_after is None
422
+
423
+
424
+ def test_userinfo_429_is_retriable_with_retry_after(oidc_routes):
425
+ """Code review finding 4: the backoff loop this release invites must
426
+ work when the same rate limiter throttles userinfo during login."""
427
+ oidc_routes.get(METADATA["userinfo_endpoint"]).mock(
428
+ return_value=httpx.Response(
429
+ 429, text="rate limited", headers={"Retry-After": "30"}
430
+ )
431
+ )
432
+ with pytest.raises(UtaServerError) as exc_info:
433
+ userinfo("at-123")
434
+ assert exc_info.value.retry_after == 30
435
+
436
+
437
+ def test_token_endpoint_429_is_not_a_credential_failure(oidc_routes):
438
+ """Code review finding 4: a throttled token refresh is retriable
439
+ server pushback — surfacing it as UtaTokenError makes callers log
440
+ the user out instead of backing off."""
441
+ oidc_routes.post(METADATA["token_endpoint"]).mock(
442
+ return_value=httpx.Response(
443
+ 429, text="rate limited", headers={"Retry-After": "15"}
444
+ )
445
+ )
446
+ with pytest.raises(UtaServerError) as exc_info:
447
+ refresh("rt-123")
448
+ assert exc_info.value.retry_after == 15
449
+
450
+
451
+ def test_timeout_zero_is_respected(oidc_routes, monkeypatch):
452
+ """Code review finding 8: timeout=0.0 — expressible since the float
453
+ widening — is a fail-fast probe, not falsy noise to be replaced by
454
+ the configured default."""
455
+ seen = {}
456
+ real_get = httpx.get
457
+
458
+ def spying_get(url, **kwargs):
459
+ seen["timeout"] = kwargs.get("timeout")
460
+ return real_get(url, **kwargs)
461
+
462
+ monkeypatch.setattr(httpx, "get", spying_get)
463
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
464
+ return_value=httpx.Response(200, json={"entitled": False})
465
+ )
466
+ get_entitlement("at-123", timeout=0.0)
467
+ assert seen["timeout"] == 0.0