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.
Files changed (60) hide show
  1. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/CHANGELOG.md +36 -0
  2. {updo_sdk-0.6.0/src/updo_sdk.egg-info → updo_sdk-0.6.2}/PKG-INFO +3 -1
  3. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/README.md +2 -0
  4. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/__init__.py +2 -0
  5. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/_transport.py +25 -1
  6. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/_version.py +1 -1
  7. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/aio.py +4 -2
  8. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/client.py +4 -2
  9. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/errors.py +21 -1
  10. {updo_sdk-0.6.0 → updo_sdk-0.6.2/src/updo_sdk.egg-info}/PKG-INFO +3 -1
  11. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/SOURCES.txt +2 -0
  12. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/data/openapi.json +4 -4
  13. updo_sdk-0.6.2/tests/test_cascade_delete_refused.py +69 -0
  14. updo_sdk-0.6.2/tests/test_long_retry_after.py +96 -0
  15. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/LICENSE +0 -0
  16. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/MANIFEST.in +0 -0
  17. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/docs/cli.md +0 -0
  18. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/examples/quickstart.py +0 -0
  19. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/pyproject.toml +0 -0
  20. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/setup.cfg +0 -0
  21. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/cli.py +0 -0
  22. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/codegen.py +0 -0
  23. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/config.py +0 -0
  24. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/models.py +0 -0
  25. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/py.typed +0 -0
  26. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/query.py +0 -0
  27. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/__init__.py +0 -0
  28. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/_params.py +0 -0
  29. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/base.py +0 -0
  30. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/entities.py +0 -0
  31. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/images.py +0 -0
  32. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/payments.py +0 -0
  33. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/records.py +0 -0
  34. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/resources/webhooks.py +0 -0
  35. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo/webhooks.py +0 -0
  36. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
  37. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/entry_points.txt +0 -0
  38. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/requires.txt +0 -0
  39. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/top_level.txt +0 -0
  40. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/__init__.py +0 -0
  41. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/conftest.py +0 -0
  42. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/integration/__init__.py +0 -0
  43. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/integration/test_live.py +0 -0
  44. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_approval_refusals.py +0 -0
  45. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_async.py +0 -0
  46. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_cli.py +0 -0
  47. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_client.py +0 -0
  48. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_codegen.py +0 -0
  49. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_contract.py +0 -0
  50. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_documents.py +0 -0
  51. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_downloads.py +0 -0
  52. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_entity_not_creatable.py +0 -0
  53. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_errors.py +0 -0
  54. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_images.py +0 -0
  55. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_list_fields.py +0 -0
  56. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_query.py +0 -0
  57. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_schema_hints.py +0 -0
  58. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_site_refusal.py +0 -0
  59. {updo_sdk-0.6.0 → updo_sdk-0.6.2}/tests/test_wall_clock.py +0 -0
  60. {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.0
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(method: str, status: int, attempt: int, max_retries: int) -> bool:
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:
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version (read by ``pyproject.toml``)."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.2"
@@ -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(method, response.status_code, attempt, self.config.max_retries):
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(method, response.status_code, attempt, self.config.max_retries):
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 -- one of the four throttle buckets is empty (token/tenant/user/burst)."""
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.0
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 builder capabilities: those are\ninterface needs, and they change with the interface.",
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 Atelier surface (``?binding=`` / ``?status=`` /\n``?event_id=``); rows arrive truncated + secret-masked from write time.",
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