funnelchat 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
funnelchat/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """The official Python SDK of the Funnelchat API.
2
+
3
+ from funnelchat import Funnelchat
4
+
5
+ client = Funnelchat() # reads FUNNELCHAT_API_KEY
6
+ result = client.messages.send_direct(device_id=3, phone="5491100000000", text="Hi Ana!")
7
+ print(result["message"]["id"], result["message"]["status"]) # 1002 queued
8
+
9
+ The types of the responses and of the webhook events are in `funnelchat.types`.
10
+ """
11
+
12
+ from . import webhooks
13
+ from ._client import BASE_URLS, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Funnelchat, ResponseInfo
14
+ from ._error_codes import (
15
+ ACCESS_ERROR_CODES,
16
+ GROUP_ACTION_ERROR_CODES,
17
+ IDEMPOTENCY_ERROR_CODES,
18
+ NOT_RETRYABLE_CODES,
19
+ NUMBER_ERROR_CODES,
20
+ SEND_ERROR_CODES,
21
+ TEMPLATE_SYNC_ERROR_CODES,
22
+ is_number_quota_error,
23
+ is_send_rule_error,
24
+ )
25
+ from ._errors import (
26
+ APIConnectionError,
27
+ APIConnectionTimeoutError,
28
+ APIError,
29
+ AuthenticationError,
30
+ BadRequestError,
31
+ ConflictError,
32
+ FunnelchatError,
33
+ InternalServerError,
34
+ NotFoundError,
35
+ PermissionDeniedError,
36
+ RateLimitError,
37
+ UnprocessableEntityError,
38
+ UnsupportedMediaTypeError,
39
+ WebhookVerificationError,
40
+ )
41
+ from ._pagination import Page
42
+ from ._resource import NOT_GIVEN, NotGiven
43
+ from ._routes import ROUTE_GROUPS, ROUTES, WEBHOOK_EVENTS, Route, RouteGroup, WebhookEventInfo
44
+ from ._version import __version__
45
+
46
+ __all__ = [
47
+ "Funnelchat",
48
+ "ResponseInfo",
49
+ "BASE_URLS",
50
+ "DEFAULT_TIMEOUT",
51
+ "DEFAULT_MAX_RETRIES",
52
+ "Page",
53
+ "NOT_GIVEN",
54
+ "NotGiven",
55
+ "webhooks",
56
+ "ROUTES",
57
+ "ROUTE_GROUPS",
58
+ "WEBHOOK_EVENTS",
59
+ "Route",
60
+ "RouteGroup",
61
+ "WebhookEventInfo",
62
+ "FunnelchatError",
63
+ "APIError",
64
+ "BadRequestError",
65
+ "AuthenticationError",
66
+ "PermissionDeniedError",
67
+ "NotFoundError",
68
+ "ConflictError",
69
+ "UnsupportedMediaTypeError",
70
+ "UnprocessableEntityError",
71
+ "RateLimitError",
72
+ "InternalServerError",
73
+ "APIConnectionError",
74
+ "APIConnectionTimeoutError",
75
+ "WebhookVerificationError",
76
+ "ACCESS_ERROR_CODES",
77
+ "SEND_ERROR_CODES",
78
+ "NUMBER_ERROR_CODES",
79
+ "GROUP_ACTION_ERROR_CODES",
80
+ "IDEMPOTENCY_ERROR_CODES",
81
+ "TEMPLATE_SYNC_ERROR_CODES",
82
+ "NOT_RETRYABLE_CODES",
83
+ "is_send_rule_error",
84
+ "is_number_quota_error",
85
+ "__version__",
86
+ ]
funnelchat/_client.py ADDED
@@ -0,0 +1,266 @@
1
+ """The client of the Funnelchat public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import time
9
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
10
+
11
+ import httpx
12
+
13
+ from ._error_codes import NOT_RETRYABLE_CODES
14
+ from ._errors import APIConnectionError, APIConnectionTimeoutError, APIError, FunnelchatError, request_id_of
15
+ from ._pagination import Page
16
+ from ._query import interpolate_path, serialize_query
17
+ from ._retry import is_retryable, is_send_request, retry_delay
18
+ from ._version import __version__
19
+ from .resources import ClientResources
20
+
21
+ __all__ = ["Funnelchat", "ResponseInfo", "BASE_URLS", "DEFAULT_TIMEOUT", "DEFAULT_MAX_RETRIES"]
22
+
23
+ #: The base URL of each environment: one URL for the whole public API.
24
+ BASE_URLS: Dict[str, str] = {
25
+ "production": "https://api.funnelchat.com",
26
+ "staging": "https://api-staging.funnelchat.com",
27
+ }
28
+ #: Maximum seconds of each attempt.
29
+ DEFAULT_TIMEOUT = 30.0
30
+ #: Automatic retries (see the README, Retries).
31
+ DEFAULT_MAX_RETRIES = 2
32
+
33
+
34
+ class ResponseInfo:
35
+ """What the API answered to one request, besides the body: `status`, `request_id` (the
36
+ `X-Request-Id`, quote it when you report a problem), `headers`, `method` and `url`."""
37
+
38
+ def __init__(self, status: int, headers: httpx.Headers, method: str, url: str) -> None:
39
+ self.status = status
40
+ self.headers = headers
41
+ self.method = method
42
+ self.url = url
43
+ self.request_id = request_id_of(headers)
44
+
45
+ def __repr__(self) -> str:
46
+ return f"<ResponseInfo {self.method} {self.url} {self.status} request_id={self.request_id}>"
47
+
48
+
49
+ class Funnelchat(ClientResources):
50
+ """Client of the Funnelchat public API.
51
+
52
+ from funnelchat import Funnelchat
53
+
54
+ client = Funnelchat() # reads FUNNELCHAT_API_KEY
55
+ for device in client.devices.list():
56
+ print(device["id"], device["alias"])
57
+
58
+ Responses are plain dicts (the decoded JSON), typed with the TypedDicts of `funnelchat.types`.
59
+ The key never shows up in `repr(client)` or in an error.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ api_key: Optional[str] = None,
66
+ environment: str = "production",
67
+ base_url: Optional[str] = None,
68
+ timeout: float = DEFAULT_TIMEOUT,
69
+ max_retries: int = DEFAULT_MAX_RETRIES,
70
+ default_headers: Optional[Mapping[str, str]] = None,
71
+ http_client: Optional[httpx.Client] = None,
72
+ ) -> None:
73
+ """
74
+ - `api_key`: `<id>|fc_live_...`. Default: the `FUNNELCHAT_API_KEY` environment variable.
75
+ - `environment`: `production` (`https://api.funnelchat.com`) or `staging`.
76
+ - `base_url`: another base URL (for example the sandbox's); it wins over `environment`.
77
+ - `timeout`: maximum seconds of each attempt. Default 30.
78
+ - `max_retries`: automatic retries on `429`, `5xx` and network errors. Default 2; 0 turns them off.
79
+ - `default_headers`: headers sent with every request.
80
+ - `http_client`: your own `httpx.Client` (proxies, transports…). You close it.
81
+ """
82
+ key = api_key if api_key is not None else os.environ.get("FUNNELCHAT_API_KEY", "")
83
+ key = key.strip()
84
+ if not key:
85
+ raise FunnelchatError(
86
+ "Missing API key: pass it as `api_key` or in the FUNNELCHAT_API_KEY environment variable."
87
+ )
88
+ if environment not in BASE_URLS:
89
+ raise FunnelchatError(f'Unknown environment: {environment!r}. Use "production" or "staging".')
90
+ self._api_key = key
91
+ self.base_url = (base_url or BASE_URLS[environment]).rstrip("/")
92
+ self.timeout = float(timeout)
93
+ self.max_retries = int(max_retries)
94
+ self._default_headers = dict(default_headers or {})
95
+ self._owns_http = http_client is None
96
+ self._http = http_client if http_client is not None else httpx.Client()
97
+ self._sleep: Callable[[float], None] = time.sleep
98
+ #: The status, headers and `X-Request-Id` of the last response this client got.
99
+ self.last_response: Optional[ResponseInfo] = None
100
+ self._init_resources()
101
+
102
+ def __repr__(self) -> str:
103
+ return f"Funnelchat(base_url={self.base_url!r}, timeout={self.timeout:g}, max_retries={self.max_retries})"
104
+
105
+ def close(self) -> None:
106
+ """Closes the HTTP connections (only those of the httpx.Client the SDK built)."""
107
+ if self._owns_http:
108
+ self._http.close()
109
+
110
+ def __enter__(self) -> "Funnelchat":
111
+ return self
112
+
113
+ def __exit__(self, *exc: object) -> None:
114
+ self.close()
115
+
116
+ # -- any route ------------------------------------------------------------------------------
117
+
118
+ def request(
119
+ self,
120
+ method: str,
121
+ path: str,
122
+ *,
123
+ path_params: Optional[Mapping[str, Any]] = None,
124
+ query: Optional[Mapping[str, Any]] = None,
125
+ body: Any = None,
126
+ headers: Optional[Mapping[str, str]] = None,
127
+ timeout: Optional[float] = None,
128
+ max_retries: Optional[int] = None,
129
+ retry_send: bool = False,
130
+ idempotency_key: Optional[str] = None,
131
+ ) -> Any:
132
+ """Calls any route and returns the decoded body (None for an empty answer).
133
+
134
+ contact = client.request("GET", "/api/v1/contacts/{contact}", path_params={"contact": 2001})
135
+ """
136
+ body_out, _ = self._send(
137
+ method,
138
+ path,
139
+ path_params=path_params,
140
+ query=query,
141
+ body=body,
142
+ headers=headers,
143
+ timeout=timeout,
144
+ max_retries=max_retries,
145
+ retry_send=retry_send,
146
+ idempotency_key=idempotency_key,
147
+ )
148
+ return body_out
149
+
150
+ def paginate(
151
+ self,
152
+ path: str,
153
+ *,
154
+ path_params: Optional[Mapping[str, Any]] = None,
155
+ query: Optional[Mapping[str, Any]] = None,
156
+ headers: Optional[Mapping[str, str]] = None,
157
+ timeout: Optional[float] = None,
158
+ max_retries: Optional[int] = None,
159
+ ) -> Page[Any]:
160
+ """Fetches the first page of any list route; iterate it for every item of every page."""
161
+ first_query = dict(query or {})
162
+
163
+ def fetch(page_query: Dict[str, Any]) -> Tuple[Any, Optional[str]]:
164
+ return self._send(
165
+ "GET",
166
+ path,
167
+ path_params=path_params,
168
+ query=page_query,
169
+ headers=headers,
170
+ timeout=timeout,
171
+ max_retries=max_retries,
172
+ )
173
+
174
+ body, request_id = fetch(first_query)
175
+ return Page(fetch, first_query, body, request_id)
176
+
177
+ # -- transport --------------------------------------------------------------------------------
178
+
179
+ def _send(
180
+ self,
181
+ method: str,
182
+ template: str,
183
+ *,
184
+ path_params: Optional[Mapping[str, Any]] = None,
185
+ query: Optional[Mapping[str, Any]] = None,
186
+ body: Any = None,
187
+ headers: Optional[Mapping[str, str]] = None,
188
+ timeout: Optional[float] = None,
189
+ max_retries: Optional[int] = None,
190
+ retry_send: bool = False,
191
+ idempotency_key: Optional[str] = None,
192
+ ) -> Tuple[Any, Optional[str]]:
193
+ verb = method.upper()
194
+ pathname = interpolate_path(template, path_params)
195
+ url = self.base_url + (pathname if pathname.startswith("/") else f"/{pathname}")
196
+ attempts = max(0, self.max_retries if max_retries is None else int(max_retries))
197
+ attempt_timeout = self.timeout if timeout is None else float(timeout)
198
+ is_send = is_send_request(verb, pathname.split("?")[0])
199
+ # A send is never repeated unless the caller asks; then it follows the rule of every write.
200
+ retries_allowed = retry_send or not is_send
201
+ # With an idempotency key the API answers the same resource to a repeated create, so it is
202
+ # retried like a PUT (as in funnelchat-php). A send is not, key or no key.
203
+ retry_as = "PUT" if idempotency_key is not None and not is_send else verb
204
+
205
+ request_headers: Dict[str, str] = {
206
+ "Accept": "application/json",
207
+ "User-Agent": f"funnelchat-python/{__version__} python/{platform.python_version()}",
208
+ }
209
+ content: Optional[bytes] = None
210
+ if body is not None:
211
+ content = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8")
212
+ request_headers["Content-Type"] = "application/json"
213
+ request_headers.update(self._default_headers)
214
+ request_headers.update(headers or {})
215
+ if idempotency_key is not None:
216
+ if not 1 <= len(idempotency_key) <= 255:
217
+ raise FunnelchatError("`idempotency_key` must have between 1 and 255 characters.")
218
+ request_headers["Idempotency-Key"] = idempotency_key
219
+ request_headers["Authorization"] = f"Bearer {self._api_key}"
220
+ params = tuple(serialize_query(query))
221
+
222
+ attempt = 0
223
+ while True:
224
+ can_retry = retries_allowed and attempt < attempts
225
+ try:
226
+ response = self._http.request(
227
+ verb, url, params=params, content=content, headers=request_headers, timeout=attempt_timeout
228
+ )
229
+ except httpx.TimeoutException:
230
+ failure: APIConnectionError = APIConnectionTimeoutError(attempt_timeout)
231
+ except httpx.RequestError as error:
232
+ failure = APIConnectionError(f"Could not connect to the Funnelchat API: {type(error).__name__}.")
233
+ else:
234
+ self.last_response = ResponseInfo(response.status_code, response.headers, verb, url)
235
+ decoded = _decode(response)
236
+ if response.is_success:
237
+ return decoded, self.last_response.request_id
238
+ api_error = APIError.from_response(
239
+ status=response.status_code, body=decoded, headers=response.headers, method=verb, url=url
240
+ )
241
+ if can_retry and is_retryable(retry_as, response.status_code) and api_error.code not in NOT_RETRYABLE_CODES:
242
+ delay = retry_delay(attempt, api_error.retry_after)
243
+ if delay is not None:
244
+ self._sleep(delay)
245
+ attempt += 1
246
+ continue
247
+ raise api_error
248
+ if can_retry and is_retryable(retry_as, None):
249
+ self._sleep(retry_delay(attempt, None) or 0.0)
250
+ attempt += 1
251
+ continue
252
+ raise failure
253
+
254
+
255
+ def _decode(response: httpx.Response) -> Any:
256
+ """The body: JSON when it parses, the text otherwise, None when there is none (a `204`)."""
257
+ text = response.text
258
+ if text == "":
259
+ return None
260
+ content_type = response.headers.get("content-type", "")
261
+ if "json" in content_type or text.lstrip()[:1] in ("{", "["):
262
+ try:
263
+ return json.loads(text)
264
+ except ValueError:
265
+ return text
266
+ return text
@@ -0,0 +1,142 @@
1
+ """The stable `error` codes of the API (`APIError.code`), with their HTTP status.
2
+
3
+ Decide by `status` and `code`, never by `message`: its text and language change by area. The same
4
+ tables as funnelchat-node 0.4.0 (src/core/error-codes.ts). Sources, all in production on
5
+ 04-11-2026: the key gate as accounts v1.5.97 runs it; the API entry as deployed to production on
6
+ 25-10-2026; conversations v1.19.7 (the send rules and the checks before sending a form); accounts
7
+ v1.5.97 (creating and connecting numbers); communities v2.35.5 and v2.35.7 (the rejections of a
8
+ group action). The spec the portal publishes (portal v0.6.13) documents some of these codes in the
9
+ operations that answer them, but not those of the API entry and the key gate. When it documents all
10
+ of them, these tables can be generated.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Dict, FrozenSet, Optional
16
+
17
+ __all__ = [
18
+ "ACCESS_ERROR_CODES",
19
+ "SEND_ERROR_CODES",
20
+ "NUMBER_ERROR_CODES",
21
+ "GROUP_ACTION_ERROR_CODES",
22
+ "IDEMPOTENCY_ERROR_CODES",
23
+ "TEMPLATE_SYNC_ERROR_CODES",
24
+ "NOT_RETRYABLE_CODES",
25
+ "is_send_rule_error",
26
+ "is_number_quota_error",
27
+ ]
28
+
29
+ #: Errors of the API entry and of the key gate, on any route.
30
+ ACCESS_ERROR_CODES: Dict[str, int] = {
31
+ "unauthenticated": 401, # missing or invalid API key
32
+ "permission_denied": 403, # the key lacks the permission the route needs (`message` names it)
33
+ "route_not_available": 403, # the route is not open to API keys (or is turned off)
34
+ "route_not_found": 404, # the path does not exist in the public API
35
+ "daily_cap_exceeded": 429, # with Retry-After: the daily call cap of the key in that area
36
+ "edge_misconfigured": 500, # our problem: report it with the request id
37
+ "origin_unreachable": 502, # no guarantee the area did not get the call: read the state after a write
38
+ "gate_unavailable": 503, # with Retry-After: nothing ran; safe to retry
39
+ }
40
+
41
+ #: Rejections of a send with an API key: nothing was sent and nothing was stored.
42
+ SEND_ERROR_CODES: Dict[str, int] = {
43
+ "rate_limited": 429, # with Retry-After: the number's pace
44
+ "mac_lockout_24h": 429, # 24-hour lockout after using up the monthly active contacts; not worth retrying
45
+ "mac_limit_reached": 429, # the monthly active contacts are used up; not worth retrying
46
+ "window_closed": 422, # free text outside the 24-hour window on an official number: send a template
47
+ "antiblock_duplicate_content": 422, # the same content again to a contact that did not answer
48
+ "antiblock_unanswered_limit": 422, # too many messages in a row to a contact that did not answer
49
+ "recipient_mismatch": 422, # the `phone` / `whatsapp_id` is not the contact of the route
50
+ "recipient_unsupported": 422, # the contact is a group, a broadcast list or a channel
51
+ "provider_unsupported": 422, # the number cannot send this (see its `capabilities`)
52
+ "device_unavailable": 422, # the number is not connected or cannot send now
53
+ "not_sent": 422, # the message could not be registered for sending
54
+ "recipient_unreachable": 422, # the contact cannot be reached on WhatsApp (forms)
55
+ "contact_blocked": 422, # the contact is blocked on that number (forms)
56
+ "template_unresolved_placeholder": 422, # a template variable points to a field that does not exist
57
+ "template_not_found": 404, # the template is not one this account can send
58
+ "form_not_found": 404, # the form does not exist
59
+ "form_not_published": 422, # the form is not published
60
+ "form_not_on_number": 422, # the form is not published on that number
61
+ "number_not_eligible": 422, # the number cannot send forms
62
+ }
63
+
64
+ #: Rejections of creating a number, asking for its QR or creating, reading or revoking a connect
65
+ #: link: nothing was created. A code with two statuses has the one of the create here.
66
+ NUMBER_ERROR_CODES: Dict[str, int] = {
67
+ "numbers_not_included": 403, # the plan does not include numbers
68
+ "number_limit_reached": 403, # every number of the plan is in use (`limit`, `used`, `pending_device_ids`)
69
+ "connect_not_allowed": 403, # the account cannot connect numbers now
70
+ "payment_required": 403, # `devices.qr()` of a number whose payment is due
71
+ "pending_number_exists": 409, # a QR number is already waiting to be scanned; the body brings its `device_id`
72
+ "qr_requires_coexistence": 422, # (409 on `devices.qr()`) QR only on an official number in coexistence
73
+ "method_already_present": 422, # the number already has that method; the body brings its `device_id`
74
+ "invalid_reference_device": 422, # `same_number_as` or `device_id` is not a number that can take it
75
+ "connection_type_mismatch": 422, # a link with `device_id` of a number that connects another way
76
+ "number_suspended_by_plan": 422, # (409 on `devices.qr()`) suspended: the plan has fewer numbers
77
+ "invalid_request": 422, # the body is not valid; `errors` says which fields
78
+ "trial_number_blocked": 409, # `devices.qr()` of a trial number that is blocked
79
+ "device_inactive": 409, # `devices.qr()` of a number that is turned off
80
+ "provisioning_quota_exceeded": 409, # `devices.qr()` when no more QR sessions can start now
81
+ "link_not_pending": 409, # revoking a connect link that already ended
82
+ "too_many_open_links": 429, # 20 connect links open: revoke some; not worth retrying
83
+ "provider_error": 500, # (502 on `devices.qr()`) the WhatsApp provider failed
84
+ "meta_connect_disabled": 503, # official numbers cannot be connected by link yet; QR links work
85
+ "credentials_not_allowed": 403, # the connect page was opened with a key or a session
86
+ }
87
+
88
+ #: Rejections of a message to a group, of following a group and of reading a group action: nothing
89
+ #: was queued or imported. A number or group of another workspace is answered as missing.
90
+ GROUP_ACTION_ERROR_CODES: Dict[str, int] = {
91
+ "device_not_found": 404, # `device_id` is not a number of the key's workspace
92
+ "group_not_found": 404, # the group is not one of the key's workspace
93
+ "action_not_found": 404, # `groups.retrieve_action()` of an action that does not exist
94
+ "unsupported_group_type": 422, # the uid is a community or a channel, not a group
95
+ "device_not_connected": 422, # the number is not connected
96
+ "device_not_group_admin": 422, # a QR number that is not admin of the group
97
+ "device_not_group_number": 422, # an official group that belongs to another number
98
+ "provider_unsupported": 422, # the number cannot do it (`provider`, maybe `requires`, in the body)
99
+ "window_closed": 422, # an official number outside the 24-hour window of the group
100
+ "number_restricted": 422, # Meta restricted the official number
101
+ "unsupported_type": 422, # that `type` cannot be sent to this group by this number
102
+ "too_many_pending_actions": 429, # 50 group actions open on the number; not worth retrying
103
+ "import_permission_required": 403, # following a group needs Groups and campaigns → Manage groups
104
+ "import_rate_limited": 429, # with Retry-After: the number already followed 5 groups this minute
105
+ "import_in_progress": 409, # another call is following the same group right now
106
+ }
107
+
108
+ #: Rejections of a create sent with `idempotency_key`.
109
+ IDEMPOTENCY_ERROR_CODES: Dict[str, int] = {
110
+ "idempotency_key_mismatch": 422, # the key was already used with another body
111
+ "invalid_idempotency_key": 422, # the key is not 1 to 255 characters
112
+ "operation_in_progress": 409, # the first call with that key is still running
113
+ }
114
+
115
+ #: Rejections of bringing a number's templates from Meta (`templates.sync()`), as the spec the portal
116
+ #: publishes (portal v0.6.13) enumerates them for `POST /api/v1/devices/{device}/templates`. A number
117
+ #: that does not exist, or is of another workspace, is a `404` without a code.
118
+ TEMPLATE_SYNC_ERROR_CODES: Dict[str, int] = {
119
+ "template_sync_in_progress": 409, # one is already running for that number: do not start another
120
+ "number_not_connected_to_meta": 422, # a QR number, or one without a WhatsApp Business account or credentials
121
+ "meta_rejected": 422, # Meta refused and nothing was brought; its code in `meta_code`
122
+ "meta_unavailable": 502, # Meta did not answer
123
+ }
124
+
125
+ #: Codes a retry would only repeat: never retried automatically.
126
+ NOT_RETRYABLE_CODES: FrozenSet[str] = frozenset(
127
+ {"mac_lockout_24h", "mac_limit_reached", "too_many_open_links", "too_many_pending_actions", "meta_connect_disabled"}
128
+ )
129
+
130
+ _QUOTA_CODES = frozenset(
131
+ {"numbers_not_included", "number_limit_reached", "pending_number_exists", "qr_requires_coexistence", "method_already_present"}
132
+ )
133
+
134
+
135
+ def is_send_rule_error(code: Optional[str]) -> bool:
136
+ """Whether an `APIError.code` is a rejection of a send: nothing was sent and nothing was stored."""
137
+ return code is not None and code in SEND_ERROR_CODES
138
+
139
+
140
+ def is_number_quota_error(code: Optional[str]) -> bool:
141
+ """Whether an `APIError.code` is a rejection by the number quota of the plan (the same as the panel's)."""
142
+ return code is not None and code in _QUOTA_CODES