updo-sdk 0.6.0__tar.gz → 0.6.2__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.
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/CHANGELOG.md +36 -0
- {updo_sdk-0.6.0/src/updo_sdk.egg-info → updo_sdk-0.6.2}/PKG-INFO +3 -1
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/README.md +2 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/__init__.py +2 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/_transport.py +25 -1
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/_version.py +1 -1
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/aio.py +4 -2
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/client.py +4 -2
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/errors.py +21 -1
- {updo_sdk-0.6.0 → updo_sdk-0.6.2/src/updo_sdk.egg-info}/PKG-INFO +3 -1
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/SOURCES.txt +2 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/data/openapi.json +4 -4
- updo_sdk-0.6.2/tests/test_cascade_delete_refused.py +69 -0
- updo_sdk-0.6.2/tests/test_long_retry_after.py +96 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/LICENSE +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/MANIFEST.in +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/docs/cli.md +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/examples/quickstart.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/pyproject.toml +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/setup.cfg +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/cli.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/codegen.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/config.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/models.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/py.typed +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/query.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/__init__.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/_params.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/base.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/entities.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/images.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/payments.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/records.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/webhooks.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/webhooks.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/entry_points.txt +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/requires.txt +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/top_level.txt +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/__init__.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/conftest.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/integration/__init__.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/integration/test_live.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_approval_refusals.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_async.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_cli.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_client.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_codegen.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_contract.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_documents.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_downloads.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_entity_not_creatable.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_errors.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_images.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_list_fields.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_query.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_schema_hints.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_site_refusal.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_wall_clock.py +0 -0
- {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_webhooks.py +0 -0
|
@@ -4,6 +4,42 @@ Format: [Keep a Changelog](https://keepachangelog.com/1.1.0/).
|
|
|
4
4
|
Versioning: [SemVer](https://semver.org/) — on the SDK's own surface, not on the
|
|
5
5
|
platform's. A backwards-compatible server change never forces a major bump here.
|
|
6
6
|
|
|
7
|
+
## [0.6.2] — 2026-10-18
|
|
8
|
+
|
|
9
|
+
Follows Qlaris delivery 14 (which carries the end of delivery 13), checked
|
|
10
|
+
against the OpenAPI document production serves on 2026-10-18: no new route and
|
|
11
|
+
no new shape, one new refusal code. The contract fixture is refreshed to it.
|
|
12
|
+
|
|
13
|
+
### A delete refused for what it would reach
|
|
14
|
+
|
|
15
|
+
- `CascadeDeleteRefused` -- 403 `CASCADE_DELETE_REFUSED`: deleting the record
|
|
16
|
+
would remove (cascade) or rewrite (set to null) dependent records this token
|
|
17
|
+
may not delete or change, or only through an approval. The WHOLE delete is
|
|
18
|
+
refused, before anything is written. Not a refusal of the record itself:
|
|
19
|
+
deal with those records first, or ask someone who may; `exc.detail` says
|
|
20
|
+
which. A `PermissionDenied`, promoted only on a 403 with the code, so
|
|
21
|
+
`except PermissionDenied` keeps catching it. Sync and async.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## [0.6.1] — 2026-10-18
|
|
26
|
+
|
|
27
|
+
Fix only.
|
|
28
|
+
|
|
29
|
+
### A long `Retry-After` is raised, not replayed too early
|
|
30
|
+
|
|
31
|
+
- The SDK caps a wait at 30 s, and used to replay even when the server asked
|
|
32
|
+
for longer -- before the moment it named, so certain to be refused again.
|
|
33
|
+
An hourly bucket answering `Retry-After: 3540` made every caller block about
|
|
34
|
+
90 s (three capped waits) for three failures.
|
|
35
|
+
- Now, when `Retry-After` (in seconds) exceeds the cap, the error is raised AT
|
|
36
|
+
ONCE -- `RateLimitError` for a 429, `ServerError` for a 503 -- with
|
|
37
|
+
`retry_after` for the caller to schedule. Exactly 30 s is still waited; an
|
|
38
|
+
absent or unparsable header keeps the ordinary backoff. Sync, async, and
|
|
39
|
+
the download path (`export()`, `pdf()`).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
7
43
|
## [0.6.0] — 2026-10-16
|
|
8
44
|
|
|
9
45
|
Follows Qlaris deliveries 11 and 12, in production since 2026-10-15. Checked
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: updo-sdk
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Python SDK for the Updo360 (Qlaris) public ERP API
|
|
5
5
|
Author: UPDO Technologies Inc.
|
|
6
6
|
License-Expression: MIT
|
|
@@ -815,6 +815,7 @@ from updo import (
|
|
|
815
815
|
ApprovalNotPossible,
|
|
816
816
|
SiteNotAllowed,
|
|
817
817
|
EntityNotCreatable,
|
|
818
|
+
CascadeDeleteRefused,
|
|
818
819
|
PlanLimitExceeded,
|
|
819
820
|
DownloadInterrupted,
|
|
820
821
|
RateLimitError,
|
|
@@ -841,6 +842,7 @@ The server speaks **two error dialects** (the platform envelope
|
|
|
841
842
|
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
842
843
|
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
|
|
843
844
|
| 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
|
|
845
|
+
| 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
|
|
844
846
|
| 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
|
|
845
847
|
| 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
|
|
846
848
|
| 404 | `NotFoundError` | entity or record does not exist |
|
|
@@ -779,6 +779,7 @@ from updo import (
|
|
|
779
779
|
ApprovalNotPossible,
|
|
780
780
|
SiteNotAllowed,
|
|
781
781
|
EntityNotCreatable,
|
|
782
|
+
CascadeDeleteRefused,
|
|
782
783
|
PlanLimitExceeded,
|
|
783
784
|
DownloadInterrupted,
|
|
784
785
|
RateLimitError,
|
|
@@ -805,6 +806,7 @@ The server speaks **two error dialects** (the platform envelope
|
|
|
805
806
|
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
806
807
|
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
|
|
807
808
|
| 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
|
|
809
|
+
| 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
|
|
808
810
|
| 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
|
|
809
811
|
| 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
|
|
810
812
|
| 404 | `NotFoundError` | entity or record does not exist |
|
|
@@ -21,6 +21,7 @@ from .errors import (
|
|
|
21
21
|
ApprovalRequired,
|
|
22
22
|
ApprovalRequiredElsewhere,
|
|
23
23
|
AuthenticationError,
|
|
24
|
+
CascadeDeleteRefused,
|
|
24
25
|
ConflictError,
|
|
25
26
|
DownloadInterrupted,
|
|
26
27
|
EntityNotCreatable,
|
|
@@ -75,6 +76,7 @@ __all__ = [
|
|
|
75
76
|
"ApprovalRequired",
|
|
76
77
|
"AsyncUpdoClient",
|
|
77
78
|
"AuthenticationError",
|
|
79
|
+
"CascadeDeleteRefused",
|
|
78
80
|
"ClientConfig",
|
|
79
81
|
"ConflictError",
|
|
80
82
|
"DocumentTemplate",
|
|
@@ -33,6 +33,7 @@ from .errors import (
|
|
|
33
33
|
from .models import ApprovalPending
|
|
34
34
|
|
|
35
35
|
__all__ = [
|
|
36
|
+
"waits_too_long",
|
|
36
37
|
"export_truncated",
|
|
37
38
|
"TRUNCATED_HEADER",
|
|
38
39
|
"EXPORT_MAX_ROWS",
|
|
@@ -168,12 +169,25 @@ def prepare_params(*sources: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
|
168
169
|
return merged
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
def should_retry_status(
|
|
172
|
+
def should_retry_status(
|
|
173
|
+
method: str,
|
|
174
|
+
status: int,
|
|
175
|
+
attempt: int,
|
|
176
|
+
max_retries: int,
|
|
177
|
+
response: httpx.Response | None = None,
|
|
178
|
+
) -> bool:
|
|
172
179
|
"""Whether to replay after an HTTP error response."""
|
|
173
180
|
if attempt >= max_retries:
|
|
174
181
|
return False
|
|
175
182
|
if status not in RETRY_STATUSES:
|
|
176
183
|
return False
|
|
184
|
+
if response is not None and waits_too_long(response):
|
|
185
|
+
# The server named a moment further away than this SDK will wait.
|
|
186
|
+
# Retrying earlier -- the wait is capped at _BACKOFF_CAP -- can only be
|
|
187
|
+
# refused again: an hourly bucket answered `Retry-After: 3540` made
|
|
188
|
+
# every caller block ~90 s for three certain failures. Raise now, and
|
|
189
|
+
# let the caller schedule with the error's `retry_after`.
|
|
190
|
+
return False
|
|
177
191
|
if status == 429:
|
|
178
192
|
# The request was rejected by the throttle BEFORE any side effect, so
|
|
179
193
|
# replaying is safe even for a POST.
|
|
@@ -193,6 +207,16 @@ def should_retry_exception(method: str, exc: Exception, attempt: int, max_retrie
|
|
|
193
207
|
return False
|
|
194
208
|
|
|
195
209
|
|
|
210
|
+
def waits_too_long(response: httpx.Response) -> bool:
|
|
211
|
+
"""Whether the response's ``Retry-After`` is beyond what the SDK waits.
|
|
212
|
+
|
|
213
|
+
Only an explicit number of seconds counts; an absent or unparsable header
|
|
214
|
+
leaves the ordinary backoff in charge.
|
|
215
|
+
"""
|
|
216
|
+
explicit = parse_retry_after(response.headers.get("Retry-After"))
|
|
217
|
+
return explicit is not None and explicit > _BACKOFF_CAP
|
|
218
|
+
|
|
219
|
+
|
|
196
220
|
def retry_delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
197
221
|
"""Seconds to wait before the next attempt (``Retry-After`` wins if sent)."""
|
|
198
222
|
if response is not None:
|
|
@@ -247,7 +247,9 @@ class AsyncUpdoClient:
|
|
|
247
247
|
continue
|
|
248
248
|
raise wrap_transport_error(exc) from exc
|
|
249
249
|
|
|
250
|
-
if should_retry_status(
|
|
250
|
+
if should_retry_status(
|
|
251
|
+
method, response.status_code, attempt, self.config.max_retries, response
|
|
252
|
+
):
|
|
251
253
|
delay = retry_delay(attempt, response)
|
|
252
254
|
await response.aclose()
|
|
253
255
|
await asyncio.sleep(delay)
|
|
@@ -276,7 +278,7 @@ class AsyncUpdoClient:
|
|
|
276
278
|
"GET", url, params=query or None, headers=headers
|
|
277
279
|
) as response:
|
|
278
280
|
if should_retry_status(
|
|
279
|
-
"GET", response.status_code, attempt, self.config.max_retries
|
|
281
|
+
"GET", response.status_code, attempt, self.config.max_retries, response
|
|
280
282
|
):
|
|
281
283
|
delay = retry_delay(attempt, response)
|
|
282
284
|
await asyncio.sleep(delay)
|
|
@@ -251,7 +251,9 @@ class UpdoClient:
|
|
|
251
251
|
continue
|
|
252
252
|
raise wrap_transport_error(exc) from exc
|
|
253
253
|
|
|
254
|
-
if should_retry_status(
|
|
254
|
+
if should_retry_status(
|
|
255
|
+
method, response.status_code, attempt, self.config.max_retries, response
|
|
256
|
+
):
|
|
255
257
|
delay = retry_delay(attempt, response)
|
|
256
258
|
response.close()
|
|
257
259
|
time.sleep(delay)
|
|
@@ -281,7 +283,7 @@ class UpdoClient:
|
|
|
281
283
|
"GET", url, params=query or None, headers=headers
|
|
282
284
|
) as response:
|
|
283
285
|
if should_retry_status(
|
|
284
|
-
"GET", response.status_code, attempt, self.config.max_retries
|
|
286
|
+
"GET", response.status_code, attempt, self.config.max_retries, response
|
|
285
287
|
):
|
|
286
288
|
delay = retry_delay(attempt, response)
|
|
287
289
|
time.sleep(delay)
|
|
@@ -23,6 +23,7 @@ from typing import Any
|
|
|
23
23
|
import httpx
|
|
24
24
|
|
|
25
25
|
__all__ = [
|
|
26
|
+
"CascadeDeleteRefused",
|
|
26
27
|
"EntityNotCreatable",
|
|
27
28
|
"ListFieldEquality",
|
|
28
29
|
"SiteNotAllowed",
|
|
@@ -267,6 +268,17 @@ class EntityNotCreatable(PermissionDenied):
|
|
|
267
268
|
return None if value is None else str(value)
|
|
268
269
|
|
|
269
270
|
|
|
271
|
+
class CascadeDeleteRefused(PermissionDenied):
|
|
272
|
+
"""403 ``CASCADE_DELETE_REFUSED`` -- the delete would reach records this token may not touch.
|
|
273
|
+
|
|
274
|
+
Deleting the record would remove (cascade) or rewrite (set to null) other
|
|
275
|
+
records that the caller may not delete or change, or only through an
|
|
276
|
+
approval. The WHOLE delete is refused before anything is written. Not a
|
|
277
|
+
refusal of the record itself: handle the dependent records first, each
|
|
278
|
+
through its own door, or ask someone who may. ``exc.detail`` says which.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
|
|
270
282
|
class SiteNotAllowed(PermissionDenied):
|
|
271
283
|
"""403 ``SITE_NOT_ALLOWED`` -- the ``X-Site-ID`` sent is not one this token may use.
|
|
272
284
|
|
|
@@ -464,7 +476,14 @@ class PlanLimitExceeded(UpdoAPIError):
|
|
|
464
476
|
|
|
465
477
|
|
|
466
478
|
class RateLimitError(UpdoAPIError):
|
|
467
|
-
"""429 --
|
|
479
|
+
"""429 -- a throttle bucket is empty (token/tenant/user/burst, or a route's own).
|
|
480
|
+
|
|
481
|
+
The SDK replays a 429 by itself only when the server's ``Retry-After`` is
|
|
482
|
+
within its own wait cap (30 s). A longer one -- an hourly bucket can name
|
|
483
|
+
the better part of an hour -- is raised AT ONCE: retrying earlier than the
|
|
484
|
+
server allows can only be refused again. Schedule the next attempt with
|
|
485
|
+
:attr:`retry_after`.
|
|
486
|
+
"""
|
|
468
487
|
|
|
469
488
|
@property
|
|
470
489
|
def retry_after(self) -> float | None:
|
|
@@ -572,6 +591,7 @@ _CODED_403: dict[str, type[PermissionDenied]] = {
|
|
|
572
591
|
**_APPROVAL_CODES,
|
|
573
592
|
"SITE_NOT_ALLOWED": SiteNotAllowed,
|
|
574
593
|
"ENTITY_NOT_CREATABLE": EntityNotCreatable,
|
|
594
|
+
"CASCADE_DELETE_REFUSED": CascadeDeleteRefused,
|
|
575
595
|
}
|
|
576
596
|
|
|
577
597
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: updo-sdk
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Python SDK for the Updo360 (Qlaris) public ERP API
|
|
5
5
|
Author: UPDO Technologies Inc.
|
|
6
6
|
License-Expression: MIT
|
|
@@ -815,6 +815,7 @@ from updo import (
|
|
|
815
815
|
ApprovalNotPossible,
|
|
816
816
|
SiteNotAllowed,
|
|
817
817
|
EntityNotCreatable,
|
|
818
|
+
CascadeDeleteRefused,
|
|
818
819
|
PlanLimitExceeded,
|
|
819
820
|
DownloadInterrupted,
|
|
820
821
|
RateLimitError,
|
|
@@ -841,6 +842,7 @@ The server speaks **two error dialects** (the platform envelope
|
|
|
841
842
|
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
842
843
|
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
|
|
843
844
|
| 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
|
|
845
|
+
| 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
|
|
844
846
|
| 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
|
|
845
847
|
| 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
|
|
846
848
|
| 404 | `NotFoundError` | entity or record does not exist |
|
|
@@ -36,6 +36,7 @@ tests/__init__.py
|
|
|
36
36
|
tests/conftest.py
|
|
37
37
|
tests/test_approval_refusals.py
|
|
38
38
|
tests/test_async.py
|
|
39
|
+
tests/test_cascade_delete_refused.py
|
|
39
40
|
tests/test_cli.py
|
|
40
41
|
tests/test_client.py
|
|
41
42
|
tests/test_codegen.py
|
|
@@ -46,6 +47,7 @@ tests/test_entity_not_creatable.py
|
|
|
46
47
|
tests/test_errors.py
|
|
47
48
|
tests/test_images.py
|
|
48
49
|
tests/test_list_fields.py
|
|
50
|
+
tests/test_long_retry_after.py
|
|
49
51
|
tests/test_query.py
|
|
50
52
|
tests/test_schema_hints.py
|
|
51
53
|
tests/test_site_refusal.py
|
|
@@ -321,7 +321,7 @@
|
|
|
321
321
|
"description": "A refusal of the public API.",
|
|
322
322
|
"properties": {
|
|
323
323
|
"code": {
|
|
324
|
-
"description": "Stable machine code. Known codes: `ACCESS_DENIED`, `APPROVAL_REQUIRED_ELSEWHERE`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`, `ENTITY_NOT_CREATABLE`, `TENANT_MEMBERSHIP_REQUIRED`, `FEATURE_NOT_LICENSED`, `FEATURE_NOT_APPLICABLE`",
|
|
324
|
+
"description": "Stable machine code. Known codes: `ACCESS_DENIED`, `APPROVAL_REQUIRED_ELSEWHERE`, `CASCADE_DELETE_REFUSED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`, `ENTITY_NOT_CREATABLE`, `TENANT_MEMBERSHIP_REQUIRED`, `FEATURE_NOT_LICENSED`, `FEATURE_NOT_APPLICABLE`",
|
|
325
325
|
"type": "string"
|
|
326
326
|
},
|
|
327
327
|
"detail": {
|
|
@@ -1416,7 +1416,7 @@
|
|
|
1416
1416
|
}
|
|
1417
1417
|
}
|
|
1418
1418
|
},
|
|
1419
|
-
"description": "Refused. `code` is one of: `ACCESS_DENIED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`."
|
|
1419
|
+
"description": "Refused. `code` is one of: `ACCESS_DENIED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `CASCADE_DELETE_REFUSED`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`."
|
|
1420
1420
|
}
|
|
1421
1421
|
},
|
|
1422
1422
|
"security": [
|
|
@@ -2017,7 +2017,7 @@
|
|
|
2017
2017
|
},
|
|
2018
2018
|
"/api/public/v1/me/": {
|
|
2019
2019
|
"get": {
|
|
2020
|
-
"description": "`GET /api/public/v1/me/` \u2014 what THIS token is, and what it can do.\n\n\u26a0\ufe0f This endpoint is the COUNTERPART of a rule: since the public API became\nmandatory, an API token no longer authenticates anywhere else. The only\nfrontend route integrations actually targeted was `/api/v1/users/context/`\n\u2014 blocking it without an equivalent would have REMOVED a capability\ninstead of relocating it, and \"mandatory\" only makes sense if the public\nsurface is sufficient.\n\nWe only return what a MACHINE client can act on: the organization the\ntoken is bound to, its scopes, and its expiry. Nothing from the screen\ncontext \u2014 no menus, preferences or
|
|
2020
|
+
"description": "`GET /api/public/v1/me/` \u2014 what THIS token is, and what it can do.\n\n\u26a0\ufe0f This endpoint is the COUNTERPART of a rule: since the public API became\nmandatory, an API token no longer authenticates anywhere else. The only\nfrontend route integrations actually targeted was `/api/v1/users/context/`\n\u2014 blocking it without an equivalent would have REMOVED a capability\ninstead of relocating it, and \"mandatory\" only makes sense if the public\nsurface is sufficient.\n\nWe only return what a MACHINE client can act on: the organization the\ntoken is bound to, its scopes, and its expiry. Nothing from the screen\ncontext \u2014 no menus, preferences or Studio capabilities: those are\ninterface needs, and they change with the interface.",
|
|
2021
2021
|
"operationId": "me_retrieve",
|
|
2022
2022
|
"parameters": [
|
|
2023
2023
|
{
|
|
@@ -2412,7 +2412,7 @@
|
|
|
2412
2412
|
},
|
|
2413
2413
|
"/api/public/v1/webhooks/deliveries/{id}/": {
|
|
2414
2414
|
"get": {
|
|
2415
|
-
"description": "The delivery journal, read-only on the machine boundary.\n\nSame filters as the
|
|
2415
|
+
"description": "The delivery journal, read-only on the machine boundary.\n\nSame filters as the Studio surface (``?binding=`` / ``?status=`` /\n``?event_id=``); rows arrive truncated + secret-masked from write time.",
|
|
2416
2416
|
"operationId": "webhooks_deliveries_retrieve",
|
|
2417
2417
|
"parameters": [
|
|
2418
2418
|
{
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""403 CASCADE_DELETE_REFUSED -- a delete that would reach records the token may not touch.
|
|
2
|
+
|
|
3
|
+
Qlaris delivery 14 (security MR !70): the delete would remove or rewrite
|
|
4
|
+
dependent records the caller may not delete or change, or only through an
|
|
5
|
+
approval. The whole delete is refused before anything is written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from updo import CascadeDeleteRefused
|
|
14
|
+
from updo.errors import ApprovalNotPossible, EntityNotCreatable, PermissionDenied, SiteNotAllowed
|
|
15
|
+
|
|
16
|
+
from .conftest import API_ROOT
|
|
17
|
+
|
|
18
|
+
RECORD = "0f8fad5b-d9cb-469f-a165-70867728950e"
|
|
19
|
+
URL = f"{API_ROOT}/data/customer/{RECORD}/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _refusal(status: int = 403) -> httpx.Response:
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
status,
|
|
25
|
+
json={
|
|
26
|
+
"code": "CASCADE_DELETE_REFUSED",
|
|
27
|
+
"detail": "Deleting this record would also delete 3 invoices you may not delete.",
|
|
28
|
+
"field_errors": None,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_a_delete_is_refused_with_its_own_class(respx_mock, client) -> None:
|
|
34
|
+
route = respx_mock.delete(URL).mock(return_value=_refusal())
|
|
35
|
+
with pytest.raises(CascadeDeleteRefused) as excinfo:
|
|
36
|
+
client.entity("customer").delete(RECORD)
|
|
37
|
+
error = excinfo.value
|
|
38
|
+
assert error.code == "CASCADE_DELETE_REFUSED"
|
|
39
|
+
assert "invoices" in error.detail
|
|
40
|
+
# A 403 like the others, but none of the other remedies applies.
|
|
41
|
+
assert isinstance(error, PermissionDenied)
|
|
42
|
+
assert not isinstance(error, (ApprovalNotPossible, SiteNotAllowed, EntityNotCreatable))
|
|
43
|
+
# A refusal of the server's judgment, not a transient fault: never replayed.
|
|
44
|
+
assert route.call_count == 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_the_code_under_another_status_is_not_promoted(respx_mock, client) -> None:
|
|
48
|
+
respx_mock.delete(URL).mock(return_value=_refusal(400))
|
|
49
|
+
with pytest.raises(Exception) as excinfo:
|
|
50
|
+
client.entity("customer").delete(RECORD)
|
|
51
|
+
assert not isinstance(excinfo.value, CascadeDeleteRefused)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_a_plain_access_denied_stays_a_plain_permission_denied(respx_mock, client) -> None:
|
|
55
|
+
respx_mock.delete(URL).mock(
|
|
56
|
+
return_value=httpx.Response(
|
|
57
|
+
403, json={"code": "ACCESS_DENIED", "detail": "x", "field_errors": None}
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
with pytest.raises(PermissionDenied) as excinfo:
|
|
61
|
+
client.entity("customer").delete(RECORD)
|
|
62
|
+
assert type(excinfo.value) is PermissionDenied
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_async_promotes_it(respx_mock, async_client) -> None:
|
|
66
|
+
respx_mock.delete(URL).mock(return_value=_refusal())
|
|
67
|
+
resource = await async_client.entity("customer")
|
|
68
|
+
with pytest.raises(CascadeDeleteRefused):
|
|
69
|
+
await resource.delete(RECORD)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A Retry-After longer than the SDK's own wait cap is raised, not replayed.
|
|
2
|
+
|
|
3
|
+
The SDK caps a wait at 30 s. Replaying sooner than the server allows can only
|
|
4
|
+
be refused again: an hourly bucket answering ``Retry-After: 3540`` used to make
|
|
5
|
+
every caller block ~90 s (three capped waits) for three certain failures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from updo import AsyncUpdoClient, UpdoClient
|
|
14
|
+
from updo.errors import RateLimitError, ServerError
|
|
15
|
+
|
|
16
|
+
from .conftest import API_ROOT, BASE_URL, TOKEN
|
|
17
|
+
|
|
18
|
+
LIST_URL = f"{API_ROOT}/data/product/"
|
|
19
|
+
PAGE = {"count": 0, "next": None, "previous": None, "results": []}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def patient_client():
|
|
24
|
+
with UpdoClient(token=TOKEN, base_url=BASE_URL, max_retries=3) as client:
|
|
25
|
+
yield client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_a_long_retry_after_is_raised_at_once(respx_mock, patient_client) -> None:
|
|
29
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
30
|
+
return_value=httpx.Response(429, headers={"Retry-After": "3540"}, json={"detail": "x"})
|
|
31
|
+
)
|
|
32
|
+
with pytest.raises(RateLimitError) as excinfo:
|
|
33
|
+
patient_client.entity("product").list()
|
|
34
|
+
assert route.call_count == 1 # no replay at all
|
|
35
|
+
assert excinfo.value.retry_after == 3540.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_a_short_retry_after_is_still_replayed(respx_mock, patient_client) -> None:
|
|
39
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
40
|
+
side_effect=[
|
|
41
|
+
httpx.Response(429, headers={"Retry-After": "5"}, json={"detail": "x"}),
|
|
42
|
+
httpx.Response(200, json=PAGE),
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
patient_client.entity("product").list()
|
|
46
|
+
assert route.call_count == 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_the_cap_itself_is_still_replayed(respx_mock, patient_client) -> None:
|
|
50
|
+
# Boundary: exactly the cap is a wait the SDK makes.
|
|
51
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
52
|
+
side_effect=[
|
|
53
|
+
httpx.Response(429, headers={"Retry-After": "30"}, json={"detail": "x"}),
|
|
54
|
+
httpx.Response(200, json=PAGE),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
patient_client.entity("product").list()
|
|
58
|
+
assert route.call_count == 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_no_retry_after_keeps_the_ordinary_backoff(respx_mock, patient_client) -> None:
|
|
62
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
63
|
+
side_effect=[httpx.Response(429, json={"detail": "x"}), httpx.Response(200, json=PAGE)]
|
|
64
|
+
)
|
|
65
|
+
patient_client.entity("product").list()
|
|
66
|
+
assert route.call_count == 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_a_long_retry_after_on_a_503_too(respx_mock, patient_client) -> None:
|
|
70
|
+
# Not specific to throttling: any server that names a far moment.
|
|
71
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
72
|
+
return_value=httpx.Response(503, headers={"Retry-After": "600"}, json={"detail": "x"})
|
|
73
|
+
)
|
|
74
|
+
with pytest.raises(ServerError):
|
|
75
|
+
patient_client.entity("product").list()
|
|
76
|
+
assert route.call_count == 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_the_download_path_too(respx_mock, patient_client) -> None:
|
|
80
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
81
|
+
return_value=httpx.Response(429, headers={"Retry-After": "3540"}, json={"detail": "x"})
|
|
82
|
+
)
|
|
83
|
+
with pytest.raises(RateLimitError):
|
|
84
|
+
patient_client.entity("product").export("csv", check_complete=False)
|
|
85
|
+
assert route.call_count == 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_async_raises_at_once(respx_mock) -> None:
|
|
89
|
+
route = respx_mock.get(LIST_URL).mock(
|
|
90
|
+
return_value=httpx.Response(429, headers={"Retry-After": "3540"}, json={"detail": "x"})
|
|
91
|
+
)
|
|
92
|
+
async with AsyncUpdoClient(token=TOKEN, base_url=BASE_URL, max_retries=3) as client:
|
|
93
|
+
resource = await client.entity("product")
|
|
94
|
+
with pytest.raises(RateLimitError):
|
|
95
|
+
await resource.list()
|
|
96
|
+
assert route.call_count == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|