Skip to content

Commit 6bcce52

Browse files
fix(errors): plan-gating is a 400, not a 403 (per platform middleware)
Verified against atoms main-backend access.middleware.ts: a plan/entitlement gate is returned as HTTP 400 with body {status:false, errors:["… please upgrade to a higher plan …"]}, not a 403. (403 is org allow-list gating, e.g. GPT 5.2 — "not available for your organization" — which the user cannot self-upgrade.) - PlanNotEntitledError now subclasses BadRequestError (400) and is specialized in BadRequestError.__new__; forbidden_error.py restored to generated. - _body_text reads the platform's `errors` array; markers match the real message ("upgrade to a higher plan" / "no access to" / "reached the maximum"). - hint_for: 400+plan-gated -> upgrade hint; 403 org-gated -> contact-account-team hint; 401 -> API-key hint. Tests use the real 400 envelope.
1 parent ea63836 commit 6bcce52

7 files changed

Lines changed: 101 additions & 68 deletions

File tree

‎.fernignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ src/smallestai/version.py
1616
# DevX: actionable error messages + PlanNotEntitledError specialization.
1717
src/smallestai/core/api_error.py
1818
src/smallestai/core/_error_hints.py
19-
src/smallestai/atoms/errors/forbidden_error.py
19+
src/smallestai/atoms/errors/bad_request_error.py
2020
src/smallestai/errors.py
2121

2222
# Customer-added files (generator never produces these)

‎src/smallestai/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from . import atoms, errors, waves
1010
from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient
1111
from .atoms.client import AsyncAtomsClient, AtomsClient
12-
from .atoms.errors.forbidden_error import PlanNotEntitledError
12+
from .atoms.errors.bad_request_error import PlanNotEntitledError
1313
from .client import AsyncSmallestAI, SmallestAI
1414
from .environment import SmallestAIEnvironment
1515
from .version import __version__
@@ -21,7 +21,7 @@
2121
"AtomsClient": ".atoms.client",
2222
"DefaultAioHttpClient": "._default_clients",
2323
"DefaultAsyncHttpxClient": "._default_clients",
24-
"PlanNotEntitledError": ".atoms.errors.forbidden_error",
24+
"PlanNotEntitledError": ".atoms.errors.bad_request_error",
2525
"SmallestAI": ".client",
2626
"SmallestAIEnvironment": ".environment",
2727
"WavesClient": ".waves.client",
Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
# This file was auto-generated by Fern from our API Definition.
1+
# Hand-maintained (see .fernignore). Kept out of regeneration to auto-specialize
2+
# a plan/entitlement-gated 400 into PlanNotEntitledError. Re-apply after a regen.
23

34
import typing
45

6+
from ...core._error_hints import looks_plan_gated
57
from ...core.api_error import ApiError
68

79

810
class BadRequestError(ApiError):
11+
def __new__(cls, body: typing.Any = None, headers: typing.Optional[typing.Dict[str, str]] = None):
12+
# The platform returns plan/entitlement gating as a 400 with an
13+
# "…upgrade to a higher plan…" message (see access.middleware.ts). Return
14+
# it as PlanNotEntitledError so callers can catch it distinctly. It
15+
# subclasses BadRequestError, so `except BadRequestError` still works.
16+
if cls is BadRequestError and looks_plan_gated(body):
17+
return super().__new__(PlanNotEntitledError)
18+
return super().__new__(cls)
19+
920
def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None):
1021
super().__init__(status_code=400, headers=headers, body=body)
22+
23+
24+
class PlanNotEntitledError(BadRequestError):
25+
"""A request rejected because the org's plan doesn't include the feature (or a
26+
plan limit was hit) — the platform returns HTTP 400 with a "…please upgrade to
27+
a higher plan…" message. Subclass of BadRequestError, so `except
28+
BadRequestError` still catches it. Gated features include campaigns, knowledge
29+
base, webhooks, integrations, phone numbers, inbound/outbound telephony, and
30+
the waves models. Detection matches the platform message
31+
(access.middleware.ts) — see SDK_ESCALATIONS.log."""
Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,10 @@
1-
# Hand-maintained (see .fernignore). Kept out of regeneration to auto-specialize
2-
# a plan-gated 403 into PlanNotEntitledError. Re-apply after a regen.
1+
# This file was auto-generated by Fern from our API Definition.
32

43
import typing
54

6-
from ...core._error_hints import looks_plan_gated
75
from ...core.api_error import ApiError
86

97

108
class ForbiddenError(ApiError):
11-
def __new__(cls, body: typing.Any = None, headers: typing.Optional[typing.Dict[str, str]] = None):
12-
# A 403 whose body looks like a plan/entitlement gate is returned as a
13-
# PlanNotEntitledError so callers can `except PlanNotEntitledError`.
14-
# It subclasses ForbiddenError, so existing `except ForbiddenError`
15-
# handlers keep working — fully backward-compatible.
16-
if cls is ForbiddenError and looks_plan_gated(body):
17-
return super().__new__(PlanNotEntitledError)
18-
return super().__new__(cls)
19-
209
def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None):
2110
super().__init__(status_code=403, headers=headers, body=body)
22-
23-
24-
class PlanNotEntitledError(ForbiddenError):
25-
"""A 403 that looks like a plan / entitlement gate — the API key is valid but
26-
the feature needs a higher plan (e.g. an Enterprise-only feature such as DNC
27-
writes). Subclass of ForbiddenError, so `except ForbiddenError` still catches
28-
it. The exact detection is a heuristic until the platform returns a stable
29-
machine-readable code (see SDK_ESCALATIONS.log)."""
Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,76 @@
1-
"""Actionable-error helpers shared by the enriched ApiError / ForbiddenError.
1+
"""Actionable-error helpers shared by the enriched ApiError / BadRequestError.
22
33
Hand-maintained (see .fernignore). Kept dependency-free (no imports of the error
44
classes) so the error modules can import it without a cycle.
5+
6+
The plan-gate detection matches the platform's payment-entitlement middleware
7+
(atoms main-backend access.middleware.ts): a gated feature is rejected with an
8+
HTTP 400 body `{ "status": false, "errors": ["… please upgrade to a higher plan …"] }`.
59
"""
610

711
import typing
812

9-
# Markers that suggest a 403 is a plan/entitlement gate (feature needs a higher
10-
# plan) rather than a generic permission denial. Best-effort heuristic until the
11-
# platform exposes a stable machine-readable code — see SDK_ESCALATIONS.log.
12-
# Kept reasonably specific to avoid mislabelling ordinary permission errors.
13+
# Substrings that identify a plan/entitlement-gated 400 (feature not on plan, or
14+
# a plan limit hit). Both platform message families contain "upgrade to a higher
15+
# plan"; the two lead-ins add resilience to small wording changes.
1316
_PLAN_GATE_MARKERS = (
14-
"your plan",
15-
"upgrade",
16-
"entitl", # entitled / entitlement
17-
"not available on your",
18-
"not enabled for your",
19-
"enterprise plan",
20-
"subscription required",
21-
"plan_upgrade_required",
22-
"feature_not_entitled",
23-
"requires a paid",
17+
"upgrade to a higher plan",
18+
"no access to",
19+
"reached the maximum",
2420
)
2521

22+
# Identifies an org allow-list gate (HTTP 403) — not plan-upgradeable by the user
23+
# (e.g. GPT 5.2, conversational-flow agents).
24+
_ORG_GATE_MARKER = "not available for your organization"
25+
2626

2727
def _body_text(body: typing.Any) -> str:
2828
if body is None:
2929
return ""
3030
if isinstance(body, str):
3131
return body
3232
if isinstance(body, dict):
33-
parts = [str(body.get(k, "")) for k in ("message", "error", "code", "detail", "reason")]
33+
# Platform error envelope is {status: false, errors: [...]}; also handle
34+
# the common single-field shapes.
35+
errors = body.get("errors")
36+
parts: typing.List[str] = []
37+
if isinstance(errors, (list, tuple)):
38+
parts.extend(str(e) for e in errors)
39+
elif errors:
40+
parts.append(str(errors))
41+
parts.extend(str(body.get(k, "")) for k in ("message", "error", "detail", "reason"))
3442
joined = " ".join(p for p in parts if p)
3543
return joined or str(body)
3644
return str(body)
3745

3846

3947
def looks_plan_gated(body: typing.Any) -> bool:
40-
"""Best-effort: does this 403 body look like a plan/entitlement gate?"""
48+
"""Best-effort: does this 400 body look like a plan/entitlement gate?"""
4149
text = _body_text(body).lower()
4250
return bool(text) and any(m in text for m in _PLAN_GATE_MARKERS)
4351

4452

53+
def _looks_org_gated(body: typing.Any) -> bool:
54+
return _ORG_GATE_MARKER in _body_text(body).lower()
55+
56+
4557
def hint_for(status_code: typing.Optional[int], body: typing.Any) -> str:
4658
"""A one-line, actionable hint to append to an error message, or '' if none."""
4759
if status_code == 401:
4860
return (
4961
"Authentication failed. Check your API key (SMALLEST_API_KEY) — "
5062
"get one at https://app.smallest.ai/dashboard/api-keys."
5163
)
64+
if status_code == 400 and looks_plan_gated(body):
65+
return (
66+
"This feature (or limit) is not included in your current plan. "
67+
"Upgrade at https://app.smallest.ai or contact your account team."
68+
)
5269
if status_code == 403:
53-
if looks_plan_gated(body):
70+
if _looks_org_gated(body):
5471
return (
55-
"This looks like a plan-gated feature. It may require a plan "
56-
"upgrade (e.g. an Enterprise feature) — check your plan at "
57-
"https://app.smallest.ai or contact your account team."
72+
"This feature is limited to specific organizations. "
73+
"Contact your account team to enable it."
5874
)
5975
return "Your API key is valid but is not permitted to perform this action."
6076
return ""

‎src/smallestai/errors.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
distinctly while ``except ForbiddenError`` still catches it.
99
"""
1010

11-
from smallestai.atoms.errors.bad_request_error import BadRequestError
11+
from smallestai.atoms.errors.bad_request_error import BadRequestError, PlanNotEntitledError
1212
from smallestai.atoms.errors.conflict_error import ConflictError
13-
from smallestai.atoms.errors.forbidden_error import ForbiddenError, PlanNotEntitledError
13+
from smallestai.atoms.errors.forbidden_error import ForbiddenError
1414
from smallestai.atoms.errors.not_found_error import NotFoundError
1515
from smallestai.atoms.errors.too_many_requests_error import TooManyRequestsError
1616
from smallestai.atoms.errors.unauthorized_error import UnauthorizedError

‎tests/custom/test_error_devx.py‎

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,64 @@
11
"""DevX: actionable error messages + PlanNotEntitledError specialization.
22
3-
A plan-gated 403 should be catchable as PlanNotEntitledError while staying a
4-
ForbiddenError (backward-compatible). Error messages should carry an actionable
5-
hint for auth (401) and plan-gated (403) failures.
3+
The platform returns plan/entitlement gating as an HTTP 400 with body
4+
`{status: false, errors: ["… please upgrade to a higher plan …"]}` (see atoms
5+
main-backend access.middleware.ts). It should surface as PlanNotEntitledError
6+
while staying a BadRequestError (backward-compatible).
67
"""
78
import unittest
89

910
from smallestai import PlanNotEntitledError as PlanNotEntitledFromRoot
10-
from smallestai.atoms.errors.forbidden_error import ForbiddenError, PlanNotEntitledError
11+
from smallestai.atoms.errors.bad_request_error import BadRequestError, PlanNotEntitledError
1112
from smallestai.atoms.errors.unauthorized_error import UnauthorizedError
1213
from smallestai.core.api_error import ApiError
1314
from smallestai.errors import PlanNotEntitledError as PlanNotEntitledFromErrors
1415

16+
_PLAN_GATED_BODY = {
17+
"status": False,
18+
"errors": ["Your plan has no access to campaigns, please upgrade to a higher plan to access this feature"],
19+
}
20+
_LIMIT_BODY = {
21+
"status": False,
22+
"errors": ["You have reached the maximum number of agents for your plan, please upgrade to a higher plan to create more agents"],
23+
}
24+
1525

1626
class ErrorDevxTest(unittest.TestCase):
17-
def test_plan_gated_403_becomes_plan_not_entitled(self):
18-
err = ForbiddenError(body={"message": "DNC is not available on your plan. Upgrade to Enterprise."})
27+
def test_plan_gated_400_becomes_plan_not_entitled(self):
28+
err = BadRequestError(body=_PLAN_GATED_BODY)
1929
self.assertIsInstance(err, PlanNotEntitledError)
20-
self.assertIsInstance(err, ForbiddenError) # backward-compatible
21-
self.assertEqual(err.status_code, 403)
30+
self.assertIsInstance(err, BadRequestError)
31+
self.assertEqual(err.status_code, 400)
32+
33+
def test_plan_limit_400_also_specializes(self):
34+
self.assertIsInstance(BadRequestError(body=_LIMIT_BODY), PlanNotEntitledError)
2235

23-
def test_generic_403_stays_forbidden(self):
24-
err = ForbiddenError(body={"message": "You do not have access to this resource."})
25-
self.assertIsInstance(err, ForbiddenError)
36+
def test_generic_400_stays_bad_request(self):
37+
err = BadRequestError(body={"status": False, "errors": ["voiceId is required"]})
38+
self.assertIsInstance(err, BadRequestError)
2639
self.assertNotIsInstance(err, PlanNotEntitledError)
2740

28-
def test_explicit_plan_error_construction(self):
29-
err = PlanNotEntitledError(body={"message": "feature_not_entitled"})
30-
self.assertIsInstance(err, PlanNotEntitledError)
31-
self.assertEqual(err.status_code, 403)
41+
def test_catch_as_plan_not_entitled(self):
42+
try:
43+
raise BadRequestError(body=_PLAN_GATED_BODY)
44+
except PlanNotEntitledError:
45+
pass
46+
else:
47+
self.fail("expected PlanNotEntitledError")
3248

3349
def test_exported_symbols_are_the_same_class(self):
3450
self.assertIs(PlanNotEntitledFromRoot, PlanNotEntitledError)
3551
self.assertIs(PlanNotEntitledFromErrors, PlanNotEntitledError)
3652

3753
def test_401_message_has_auth_hint(self):
38-
msg = str(UnauthorizedError(body={"message": "invalid token"}))
39-
self.assertIn("SMALLEST_API_KEY", msg)
54+
self.assertIn("SMALLEST_API_KEY", str(UnauthorizedError(body={"errors": ["invalid token"]})))
4055

4156
def test_plan_gated_message_has_upgrade_hint(self):
42-
msg = str(ForbiddenError(body={"message": "requires a paid plan; upgrade your plan"}))
43-
self.assertIn("plan-gated", msg.lower())
57+
msg = str(BadRequestError(body=_PLAN_GATED_BODY)).lower()
58+
self.assertIn("plan", msg)
59+
self.assertIn("upgrade", msg)
4460

45-
def test_success_case_no_hint_noise(self):
46-
# A non-auth, non-403 error keeps a clean message.
61+
def test_no_hint_noise_on_plain_error(self):
4762
msg = str(ApiError(status_code=500, body="boom"))
4863
self.assertIn("boom", msg)
4964
self.assertNotIn("SMALLEST_API_KEY", msg)

0 commit comments

Comments
 (0)