mainsequence 9.0.5__tar.gz → 9.0.6__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 (104) hide show
  1. {mainsequence-9.0.5/mainsequence.egg-info → mainsequence-9.0.6}/PKG-INFO +4 -5
  2. {mainsequence-9.0.5 → mainsequence-9.0.6}/README.md +2 -2
  3. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +100 -2
  4. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +11 -3
  5. mainsequence-9.0.6/mainsequence/client/_transport.py +154 -0
  6. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/client.py +17 -11
  7. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/inference.py +2 -2
  8. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/models_helpers.py +2 -2
  9. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/utils.py +43 -115
  10. mainsequence-9.0.6/mainsequence/server/__init__.py +1 -0
  11. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/server/caller_assertions.py +3 -7
  12. {mainsequence-9.0.5 → mainsequence-9.0.6/mainsequence.egg-info}/PKG-INFO +4 -5
  13. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence.egg-info/SOURCES.txt +2 -0
  14. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence.egg-info/requires.txt +1 -3
  15. {mainsequence-9.0.5 → mainsequence-9.0.6}/pyproject.toml +2 -5
  16. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_auth_bootstrap.py +3 -0
  17. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_auth_precedence.py +3 -0
  18. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_caller_assertions.py +3 -4
  19. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_dependency_extras.py +32 -0
  20. mainsequence-9.0.6/tests/test_http_retry_budget.py +434 -0
  21. mainsequence-9.0.5/mainsequence/server/__init__.py +0 -1
  22. {mainsequence-9.0.5 → mainsequence-9.0.6}/LICENSE +0 -0
  23. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/AGENTS.md +0 -0
  24. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  25. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  26. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  27. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  28. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  29. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  30. {mainsequence-9.0.5 → mainsequence-9.0.6}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  31. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/__init__.py +0 -0
  32. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/__main__.py +0 -0
  33. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/_request_identity.py +0 -0
  34. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/bootstrap.py +0 -0
  35. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/__init__.py +0 -0
  36. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/api.py +0 -0
  37. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/browser_auth.py +0 -0
  38. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/cli.py +0 -0
  39. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/code_repository_status.py +0 -0
  40. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/config.py +0 -0
  41. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/docker_utils.py +0 -0
  42. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/doctor.py +0 -0
  43. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/local_ops.py +0 -0
  44. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/model_filters.py +0 -0
  45. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/pydantic_cli.py +0 -0
  46. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/sdk_utils.py +0 -0
  47. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/ssh_utils.py +0 -0
  48. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/cli/ui.py +0 -0
  49. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/__init__.py +0 -0
  50. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/agent_runtime_models.py +0 -0
  51. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/base.py +0 -0
  52. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/compute_validation.py +0 -0
  53. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/exceptions.py +0 -0
  54. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/github_issues.py +0 -0
  55. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/models_foundry.py +0 -0
  56. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/models_user.py +0 -0
  57. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/observability.py +0 -0
  58. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/client/value_sets.py +0 -0
  59. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/code_repository_context.py +0 -0
  60. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/code_repository_skills.py +0 -0
  61. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/defaults.py +0 -0
  62. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/instrumentation/__init__.py +0 -0
  63. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/instrumentation/utils.py +0 -0
  64. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/logconf.py +0 -0
  65. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/repository_identity_security.py +0 -0
  66. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/runtime_flags.py +0 -0
  67. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/scaffold_skills.py +0 -0
  68. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence/server/fastapi.py +0 -0
  69. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence.egg-info/dependency_links.txt +0 -0
  70. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence.egg-info/entry_points.txt +0 -0
  71. {mainsequence-9.0.5 → mainsequence-9.0.6}/mainsequence.egg-info/top_level.txt +0 -0
  72. {mainsequence-9.0.5 → mainsequence-9.0.6}/setup.cfg +0 -0
  73. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_canonical_api_route_contract.py +0 -0
  74. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_cli.py +0 -0
  75. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_cli_auth_contract.py +0 -0
  76. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_cli_browser_auth.py +0 -0
  77. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_cli_secure_storage.py +0 -0
  78. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_client.py +0 -0
  79. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_code_repository_branch_contract.py +0 -0
  80. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_code_repository_documentation_contract.py +0 -0
  81. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_code_repository_runtime_context.py +0 -0
  82. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_code_repository_skills.py +0 -0
  83. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_dev_release_version.py +0 -0
  84. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_environment_resource_context.py +0 -0
  85. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_environment_response_contracts.py +0 -0
  86. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_fastapi_request_identity.py +0 -0
  87. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_filter_normalization.py +0 -0
  88. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_github_issues_client.py +0 -0
  89. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_independent_context.py +0 -0
  90. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_inference_client.py +0 -0
  91. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_instrumentation.py +0 -0
  92. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_job_client.py +0 -0
  93. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_local_ops.py +0 -0
  94. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_logconf.py +0 -0
  95. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_models_user_request_bound_auth.py +0 -0
  96. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_notification_uid_contract.py +0 -0
  97. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_observability_client.py +0 -0
  98. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_scaffold_skills.py +0 -0
  99. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_secret_client_model.py +0 -0
  100. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_source_ownership.py +0 -0
  101. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_ssh_utils.py +0 -0
  102. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_tolerant_response_reading.py +0 -0
  103. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_tolerant_value_sets.py +0 -0
  104. {mainsequence-9.0.5 → mainsequence-9.0.6}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.5
3
+ Version: 9.0.6
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -25,13 +25,12 @@ Requires-Dist: protobuf>=6.33.5
25
25
  Requires-Dist: psutil
26
26
  Requires-Dist: pydantic<3,>=2.11
27
27
  Requires-Dist: pygments>=2.20.0
28
+ Requires-Dist: PyJWT[crypto]<3,>=2.10
28
29
  Requires-Dist: pyyaml
29
30
  Requires-Dist: requests>=2.33.0
30
31
  Requires-Dist: structlog
31
32
  Requires-Dist: typer
32
33
  Requires-Dist: urllib3>=2.7.0
33
- Provides-Extra: server
34
- Requires-Dist: PyJWT[crypto]<3,>=2.10; extra == "server"
35
34
  Dynamic: license-file
36
35
 
37
36
  <p align="center">
@@ -75,7 +74,7 @@ Main package areas:
75
74
  authentication, platform resources, and local CodeRepository operations
76
75
  - `mainsequence.instrumentation` and `mainsequence.logconf`: SDK tracing and
77
76
  logging integration
78
- - `mainsequence.server`: optional server-side caller assertion verification
77
+ - `mainsequence.server`: server-side caller assertion verification
79
78
  - `mainsequence.scaffold_skills`: reusable, version-pinned skill copying for
80
79
  this SDK and extension libraries
81
80
 
@@ -148,7 +147,7 @@ another repository, use the explicitly scoped `filter_admin` path. See
148
147
  [Resource releases](docs/knowledge/infrastructure/resource_releases.md) for
149
148
  supported filters and examples.
150
149
 
151
- Server integrations can install `mainsequence[server]` for the
150
+ The standard `mainsequence` installation includes PyJWT and cryptography for the
152
151
  framework-independent [caller assertion verifier](docs/knowledge/server/caller_assertions.md).
153
152
  Applications remain responsible for resource authorization and storage
154
153
  operations.
@@ -39,7 +39,7 @@ Main package areas:
39
39
  authentication, platform resources, and local CodeRepository operations
40
40
  - `mainsequence.instrumentation` and `mainsequence.logconf`: SDK tracing and
41
41
  logging integration
42
- - `mainsequence.server`: optional server-side caller assertion verification
42
+ - `mainsequence.server`: server-side caller assertion verification
43
43
  - `mainsequence.scaffold_skills`: reusable, version-pinned skill copying for
44
44
  this SDK and extension libraries
45
45
 
@@ -112,7 +112,7 @@ another repository, use the explicitly scoped `filter_admin` path. See
112
112
  [Resource releases](docs/knowledge/infrastructure/resource_releases.md) for
113
113
  supported filters and examples.
114
114
 
115
- Server integrations can install `mainsequence[server]` for the
115
+ The standard `mainsequence` installation includes PyJWT and cryptography for the
116
116
  framework-independent [caller assertion verifier](docs/knowledge/server/caller_assertions.md).
117
117
  Applications remain responsible for resource authorization and storage
118
118
  operations.
@@ -248,8 +248,8 @@ The platform deploys from the push according to the repository's
248
248
  pushes deploy:
249
249
 
250
250
  - omitted or `null`: every push to the branch deploys;
251
- - a regular expression: a push deploys only when a matching tag points at the
252
- branch's latest commit.
251
+ - a regular expression: a matching tag deploys the commit it points at, whether
252
+ that is the branch's latest commit or an older commit on the branch.
253
253
 
254
254
  Automatic deployment and `tag_regex` are set in the workflow file only; a
255
255
  `ResourceRelease` or `Job` update does not accept them. The Main Sequence
@@ -300,6 +300,104 @@ only the commits CI tagged. Details:
300
300
  Do not call `sync-after-commit` or any backend repair endpoint. The GitHub
301
301
  branch-push webhook owns backend repository reconciliation.
302
302
 
303
+ ### Development Deploys Every Push, Production Deploys Tags
304
+
305
+ A common setup uses two workflow files scoped to Environments. On the
306
+ production branch only the first applies; on the development branch only the
307
+ second:
308
+
309
+ ```yaml
310
+ # .mainsequence/workflows/api-production.yaml
311
+ api_version: "2.3.0"
312
+ name: api-production
313
+ scope:
314
+ environments: [production]
315
+ resources:
316
+ - key: api
317
+ kind: fastapi
318
+ spec:
319
+ source_path: api/main.py
320
+ automatic_deployment: true
321
+ automatic_redeployment:
322
+ enabled: true
323
+ tag_regex: "^v[0-9]+\\.[0-9]+\\.[0-9]+$"
324
+ ```
325
+
326
+ ```yaml
327
+ # .mainsequence/workflows/api-development.yaml
328
+ api_version: "2.3.0"
329
+ name: api-development
330
+ scope:
331
+ environments: [development] # the development Environment's exact name
332
+ resources:
333
+ - key: api
334
+ kind: fastapi
335
+ spec:
336
+ source_path: api/main.py
337
+ automatic_deployment: true
338
+ automatic_redeployment:
339
+ enabled: true
340
+ ```
341
+
342
+ A push to the development branch deploys at once. A merge to `main` deploys
343
+ nothing until a matching tag points at a commit on `main`; with the release job
344
+ above, CI creates that tag after the tests pass. Take the exact fields from the
345
+ branch's workflow template and validate each file through the branch's
346
+ `validate-workflow` action before committing it, as the
347
+ `platform_operations/orchestration_and_releases` skill describes.
348
+
349
+ ### Creating A Release Tag
350
+
351
+ The platform reacts to the tag push, however the tag was made. The short tag
352
+ name must fully match `tag_regex`, and the tagged commit must be on the
353
+ target's branch (its latest commit or an older one).
354
+
355
+ - CI, as in the release job above. This is the recommended path.
356
+ - By hand, only when the user asks for it:
357
+
358
+ ```bash
359
+ git tag v1.3.0 <commit>
360
+ git push origin v1.3.0
361
+ ```
362
+
363
+ Lightweight and annotated tags both work. Push one tag at a time: GitHub
364
+ sends no event when more than three tags are pushed at once, so
365
+ `git push --tags` can deploy nothing.
366
+ - A GitHub Release: publishing a release with a new tag, in the web UI or with
367
+ `gh release create v1.3.0 --target main`, creates the tag, and that tag
368
+ deploys like a pushed one.
369
+
370
+ A tag created with the Actions `GITHUB_TOKEN` starts no other GitHub Actions
371
+ workflow, but it still reaches Main Sequence. Deleting a tag deploys nothing
372
+ and removes nothing. A tag that does not match, or whose commit is only on
373
+ another branch, deploys nothing for that target.
374
+
375
+ ### Going Back To An Older Version
376
+
377
+ Two ways work. Use either only when the user asks for it.
378
+
379
+ - Revert and release a new version: `git revert <commit>`, raise the patch
380
+ version with `uv version --bump patch`, commit, push, and let CI tag the
381
+ result. This works for every target, including every-push targets, and keeps
382
+ the version moving forward.
383
+ - For a target with a `tag_regex`, push the older version's tag again. GitHub
384
+ sends an event only when a tag is created, so delete the tag on GitHub and
385
+ push it again; it still points at the same commit:
386
+
387
+ ```bash
388
+ git fetch --tags origin
389
+ git push origin :refs/tags/v1.2.0
390
+ git push origin v1.2.0
391
+ ```
392
+
393
+ The target redeploys that commit exactly as it was released: its code, its
394
+ image and its workflow file, whose settings replace the current ones. The
395
+ commit must still be on the branch and the tag must match the target's
396
+ current `tag_regex`. Every-push targets ignore the tag. Re-pushing a tag
397
+ also re-runs any GitHub Actions workflow that the tag triggers, and a tag
398
+ ruleset may forbid deleting it; use the revert route then. The next release
399
+ tag deploys a newer version again.
400
+
303
401
  ## Diagnose Partial Completion Before Retrying
304
402
 
305
403
  Never rerun an entire mutating workflow blindly.
@@ -91,8 +91,16 @@ the SDK or CodeRepository code.
91
91
 
92
92
  Every file requires the backend-advertised `api_version`, a name, and resource
93
93
  declarations. Use the current template for accepted fields and resource kinds.
94
- There is no prune or strict-delete mode; removing a declaration does not delete
95
- an existing backend resource.
94
+ A declaration owns its target. On the next push of the branch head, these
95
+ delete the target:
96
+
97
+ - removing the declaration or its file;
98
+ - a `scope` that excludes the branch's Environment.
99
+
100
+ Renaming a file or key deletes the old target and creates a new one, with new
101
+ UIDs and URLs. An invalid file deletes nothing. The branch's Harness Agent
102
+ stays while any workflow file declares a `harness_agent`, so moving its
103
+ declaration keeps the same Agent.
96
104
 
97
105
  Do not hide important recurring schedules in ad hoc shell history or one-off manual commands.
98
106
 
@@ -224,7 +232,7 @@ Automatic deployment and its `tag_regex` are set in the repository's `.mainseque
224
232
  The declaration's `tag_regex` decides which pushes deploy:
225
233
 
226
234
  - omitted or `null`: every push to the branch deploys
227
- - a regular expression: a push deploys only when a matching tag points at the branch's latest commit
235
+ - a regular expression: a matching tag deploys the commit it points at, whether that is the branch's latest commit or an older commit on the branch
228
236
 
229
237
  The platform does not create or name tags. Release tags come from the repository's own CI, for example a GitHub Actions workflow that tags the version declared in `pyproject.toml` after the tests pass. The maintenance skill `.agents/skills/mainsequence/maintenance/code_repository_maintenance/SKILL.md` has an example. `mainsequence code-repository sync` only refreshes `uv.lock`, the local environment, and `requirements.txt`; it does not commit, tag, or push.
230
238
 
@@ -0,0 +1,154 @@
1
+ """One bounded retry policy for SDK HTTP sessions."""
2
+
3
+ import math
4
+ import time
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+
8
+ import requests
9
+ from requests.adapters import HTTPAdapter
10
+ from urllib3.exceptions import InvalidHeader
11
+ from urllib3.util import Retry, Timeout
12
+
13
+ DEFAULT_ALLOWED_METHODS = frozenset({"HEAD", "GET", "OPTIONS"})
14
+ DEFAULT_STATUS_FORCELIST = (429, 500, 502, 503, 504)
15
+ DEFAULT_TIMEOUT = (5.0, 120.0)
16
+
17
+
18
+ class RequestBudget:
19
+ def __init__(self, timeout):
20
+ if timeout is None:
21
+ timeout = DEFAULT_TIMEOUT
22
+ if isinstance(timeout, Timeout):
23
+ connect, read = timeout.connect_timeout, timeout.read_timeout
24
+ total = timeout.total
25
+ elif isinstance(timeout, tuple):
26
+ connect, read = timeout
27
+ total = None
28
+ else:
29
+ connect = read = total = timeout
30
+ self.connect = self._seconds(connect)
31
+ self.read = self._seconds(read)
32
+ self.deadline = time.monotonic() + self._seconds(
33
+ self.connect + self.read if total is None else total
34
+ )
35
+
36
+ @staticmethod
37
+ def _seconds(value):
38
+ seconds = float(value)
39
+ if not math.isfinite(seconds) or seconds <= 0:
40
+ raise ValueError("Request timeouts must be positive, finite seconds.")
41
+ return seconds
42
+
43
+ @property
44
+ def remaining(self):
45
+ return self.deadline - time.monotonic()
46
+
47
+ def timeout(self):
48
+ remaining = self.remaining
49
+ if remaining <= 0:
50
+ raise requests.Timeout("The SDK request's total timeout budget is exhausted.")
51
+ return Timeout(
52
+ total=remaining,
53
+ connect=min(self.connect, remaining),
54
+ read=min(self.read, remaining),
55
+ )
56
+
57
+
58
+ _ACTIVE_BUDGET: ContextVar[RequestBudget | None] = ContextVar("sdk_request_budget", default=None)
59
+
60
+
61
+ @contextmanager
62
+ def request_budget(timeout):
63
+ budget = RequestBudget(timeout)
64
+ parent = _ACTIVE_BUDGET.get()
65
+ if parent is not None:
66
+ budget.deadline = min(budget.deadline, parent.deadline)
67
+ token = _ACTIVE_BUDGET.set(budget)
68
+ try:
69
+ yield budget
70
+ finally:
71
+ _ACTIVE_BUDGET.reset(token)
72
+
73
+
74
+ def remaining_timeout(timeout):
75
+ budget = _ACTIVE_BUDGET.get()
76
+ if budget is None:
77
+ return timeout
78
+ connect, read = timeout
79
+ remaining = budget.timeout().total
80
+ return Timeout(total=remaining, connect=min(connect, remaining), read=min(read, remaining))
81
+
82
+
83
+ class DeadlineHTTPAdapter(HTTPAdapter):
84
+ def __init__(self, *, retries, backoff_factor):
85
+ if not isinstance(retries, int) or retries < 0:
86
+ raise ValueError("retries must be a non-negative integer.")
87
+ if not math.isfinite(backoff_factor) or backoff_factor < 0:
88
+ raise ValueError("backoff_factor must be non-negative and finite.")
89
+ # urllib3 must never add a second retry loop or resend a write on connect failure.
90
+ super().__init__(
91
+ max_retries=Retry(
92
+ total=0,
93
+ connect=0,
94
+ read=False,
95
+ status=0,
96
+ other=0,
97
+ allowed_methods=DEFAULT_ALLOWED_METHODS,
98
+ )
99
+ )
100
+ self.retries = retries
101
+ self.backoff_factor = backoff_factor
102
+
103
+ def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
104
+ budget = RequestBudget(timeout)
105
+ parent = _ACTIVE_BUDGET.get()
106
+ if parent is not None:
107
+ budget.deadline = min(budget.deadline, parent.deadline)
108
+ retryable = request.method in DEFAULT_ALLOWED_METHODS
109
+ for attempt in range(self.retries + 1):
110
+ response = None
111
+ error = None
112
+ try:
113
+ response = super().send(
114
+ request,
115
+ stream=stream,
116
+ timeout=budget.timeout(),
117
+ verify=verify,
118
+ cert=cert,
119
+ proxies=proxies,
120
+ )
121
+ if not stream:
122
+ # Read before deciding to retry, so body failures share the same budget.
123
+ _ = response.content
124
+ if not retryable or response.status_code not in DEFAULT_STATUS_FORCELIST:
125
+ return response
126
+ except requests.exceptions.SSLError:
127
+ raise
128
+ except (
129
+ requests.ConnectionError,
130
+ requests.Timeout,
131
+ requests.exceptions.ChunkedEncodingError,
132
+ ) as exc:
133
+ error = exc
134
+
135
+ delay = min(self.backoff_factor * 2**attempt, Retry.DEFAULT_BACKOFF_MAX)
136
+ if error is None:
137
+ try:
138
+ retry_after = self.max_retries.get_retry_after(response)
139
+ except InvalidHeader:
140
+ retry_after = None
141
+ if retry_after is not None:
142
+ delay = max(delay, retry_after)
143
+
144
+ if not retryable or attempt == self.retries or delay >= budget.remaining:
145
+ if error is not None:
146
+ if response is not None:
147
+ response.close()
148
+ raise error
149
+ return response
150
+ if response is not None:
151
+ response.close()
152
+ time.sleep(delay)
153
+
154
+ raise AssertionError("Unreachable retry state")
@@ -9,7 +9,7 @@ import requests
9
9
  from mainsequence.defaults import resolve_backend_endpoint
10
10
 
11
11
  from .exceptions import raise_for_response
12
- from .utils import AuthLoaders, build_session
12
+ from .utils import AuthLoaders, build_session, make_request
13
13
  from .utils import loaders as _global_loaders
14
14
  from .utils import session as _global_session
15
15
 
@@ -35,6 +35,7 @@ class MainSequenceClient:
35
35
  - Uses ONE shared loaders + session (from utils) by default
36
36
  - Can be reconfigured once at startup
37
37
  """
38
+
38
39
  _default: MainSequenceClient | None = None
39
40
 
40
41
  def __init__(
@@ -72,15 +73,18 @@ class MainSequenceClient:
72
73
  timeout: tuple[float, float] | float | None = None,
73
74
  ) -> Any:
74
75
  url = self.config.api_root.rstrip("/") + "/" + path.lstrip("/")
75
- to = timeout or self.config.timeout
76
-
77
- r = self.session.request(method.upper(), url, params=params, json=json, files=files, timeout=to)
78
-
79
- # Refresh auth once if needed
80
- if r.status_code in (401, 403):
81
- self.loaders.refresh_headers()
82
- self.session.headers.update(self.loaders.auth_headers)
83
- r = self.session.request(method.upper(), url, params=params, json=json, files=files, timeout=to)
76
+ to = self.config.timeout if timeout is None else timeout
77
+ payload = {"params": params, "json": json}
78
+ if files is not None:
79
+ payload["files"] = files
80
+ r = make_request(
81
+ self.session,
82
+ method,
83
+ url,
84
+ self.loaders,
85
+ payload=payload,
86
+ time_out=to,
87
+ )
84
88
 
85
89
  raise_for_response(r, payload={"params": params, "json": json})
86
90
 
@@ -95,7 +99,9 @@ class MainSequenceClient:
95
99
  """
96
100
  if cls._default is None:
97
101
  # Reuse the existing global singletons from utils to avoid two sessions in prod.
98
- cls._default = cls(MainSequenceClientConfig(), loaders=_global_loaders, session=_global_session)
102
+ cls._default = cls(
103
+ MainSequenceClientConfig(), loaders=_global_loaders, session=_global_session
104
+ )
99
105
  return cls._default
100
106
 
101
107
  @classmethod
@@ -69,8 +69,8 @@ class InferenceClient:
69
69
  if self.organization_environment_uid is not None:
70
70
  query["organization_environment_uid"] = self.organization_environment_uid
71
71
  url = f"{utils.API_ENDPOINT.rstrip('/')}/{path}"
72
- # The shared make_request loop retries network failures. Inference uses
73
- # the same auth loader with a dedicated zero-retry transport instead.
72
+ # Inference opts out of safe-read retries as well: an uncertain outcome
73
+ # must preserve the caller's explicit idempotency key.
74
74
  with utils.build_session(loaders=utils.loaders, retries=0) as session:
75
75
  headers = dict(utils.loaders.refresh_headers(force=False, session=session))
76
76
  if key is not None:
@@ -107,8 +107,8 @@ class AutomaticRedeploymentPolicy(BaseModel):
107
107
  description=(
108
108
  "Regular expression matched against repository tags, set by `tag_regex` "
109
109
  "in the repository's `.mainsequence/workflows/*.yaml`. Null deploys every "
110
- "push; a regex deploys only when a matching tag points at the branch's "
111
- "latest commit."
110
+ "push; a regex deploys the commit a matching tag points at, whether it is "
111
+ "the branch's latest commit or an older commit on the branch."
112
112
  ),
113
113
  examples=[None, r"^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$"],
114
114
  )
@@ -17,26 +17,21 @@ from uuid import UUID, getnode
17
17
 
18
18
  import psutil
19
19
  import requests
20
- from requests.adapters import HTTPAdapter
21
20
  from requests.structures import CaseInsensitiveDict
22
- from urllib3.util.retry import Retry
23
21
 
24
22
  from mainsequence.defaults import resolve_backend_endpoint
25
23
  from mainsequence.logconf import logger
26
24
 
25
+ from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
26
+ from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
27
+ from ._transport import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
28
+ from ._transport import DeadlineHTTPAdapter, remaining_timeout, request_budget
29
+
27
30
  # ---- Backend defaults (single source of truth) ----
28
31
  MAINSEQUENCE_ENDPOINT = resolve_backend_endpoint()
29
32
  API_ENDPOINT = f"{MAINSEQUENCE_ENDPOINT}/api/v1"
30
33
  AUTH_ENDPOINT = MAINSEQUENCE_ENDPOINT.rstrip("/")
31
34
 
32
- DEFAULT_STATUS_FORCELIST = (429, 500, 502, 503, 504)
33
- DEFAULT_ALLOWED_METHODS = frozenset(["HEAD", "GET", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"])
34
-
35
- # requests supports either a float or (connect, read). Preserve previous ~120s read behavior,
36
- # but add a sane connect timeout.
37
- DEFAULT_TIMEOUT: tuple[float, float] = (5.0, 120.0)
38
-
39
-
40
35
  DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
41
36
 
42
37
 
@@ -383,7 +378,7 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
383
378
  "credential_secret": credential_secret,
384
379
  },
385
380
  headers={"Content-Type": "application/json"},
386
- timeout=self.timeout,
381
+ timeout=remaining_timeout(self.timeout),
387
382
  allow_redirects=False,
388
383
  )
389
384
  if response.status_code < 200 or response.status_code >= 300:
@@ -515,7 +510,7 @@ class JWTAuthProvider(BaseAuthProvider):
515
510
  self.refresh_url,
516
511
  json={"refresh": self.refresh_token},
517
512
  headers={"Content-Type": "application/json"},
518
- timeout=self.timeout,
513
+ timeout=remaining_timeout(self.timeout),
519
514
  )
520
515
 
521
516
  if r.status_code in _REFUSED_RENEWAL_STATUSES:
@@ -564,7 +559,7 @@ class JWTAuthProvider(BaseAuthProvider):
564
559
  self.obtain_url,
565
560
  json={"username": username, "password": password},
566
561
  headers={"Content-Type": "application/json"},
567
- timeout=self.timeout,
562
+ timeout=remaining_timeout(self.timeout),
568
563
  )
569
564
  r.raise_for_status()
570
565
  data = r.json()
@@ -671,63 +666,46 @@ def make_request(
671
666
  ):
672
667
  from requests.models import Response
673
668
 
674
- TIMEOFF = 0.25
675
- TRIES = int(15 // TIMEOFF)
676
669
  timeout = DEFAULT_TIMEOUT if time_out is None else time_out
677
670
  payload = {} if payload is None else payload
678
-
679
- def get_req(session):
680
- if r_type == "GET":
681
- return session.get
682
- elif r_type == "POST":
683
- return session.post
684
- elif r_type == "PUT":
685
- return session.put
686
- elif r_type == "PATCH":
687
- return session.patch
688
- elif r_type == "DELETE":
689
- return session.delete
690
- else:
691
- raise NotImplementedError(f"Unsupported method: {r_type}")
671
+ r_type = r_type.upper()
672
+ if r_type not in DEFAULT_ALLOWED_METHODS | {"POST", "PUT", "PATCH", "DELETE"}:
673
+ raise NotImplementedError(f"Unsupported method: {r_type}")
692
674
 
693
675
  request_kwargs = {}
694
676
  if r_type in ("POST", "PATCH") and "files" in payload:
695
- request_kwargs["data"] = payload.get("json", {})
696
- request_kwargs["files"] = payload["files"]
677
+ request_kwargs = dict(payload)
678
+ request_kwargs["data"] = request_kwargs.pop("json", {})
697
679
  s.headers.pop("Content-Type", None)
698
680
  else:
699
- request_kwargs = payload
700
-
701
- req = get_req(session=s)
702
- keep_request = True
703
- counter = 0
704
- auth_retried = False
681
+ request_kwargs = dict(payload)
682
+ request_kwargs.setdefault("allow_redirects", False)
683
+ req = getattr(s, r_type.lower())
705
684
 
706
685
  if accept_gzip:
707
686
  s.headers.setdefault("Accept-Encoding", "gzip")
708
687
 
709
- while keep_request:
688
+ with request_budget(timeout) as budget:
710
689
  try:
711
- if loaders is not None:
712
- s.headers.update(loaders.refresh_headers(force=False, session=s))
713
-
714
- start_time = time.perf_counter()
715
- logger.debug(f"Requesting {r_type} from {url}")
716
- r = req(url, timeout=timeout, **request_kwargs)
717
- duration = time.perf_counter() - start_time
718
- logger.debug(f"{url} took {duration:.4f} seconds.")
719
-
720
- if r.status_code == 401 and loaders is not None and not auth_retried:
721
- logger.warning(f"Error {r.status_code}; forcing auth refresh once")
722
- # A refused renewal raises AuthError: it is reported below with the
723
- # reason and the repair, not as a traceback.
724
- s.headers.update(loaders.refresh_headers(force=True, session=s))
725
- req = get_req(session=s)
726
- auth_retried = True
727
- continue
728
-
729
- keep_request = False
730
- break
690
+ for auth_attempt in range(2):
691
+ if loaders is not None:
692
+ budget.timeout()
693
+ s.headers.update(loaders.refresh_headers(force=bool(auth_attempt), session=s))
694
+
695
+ start_time = time.perf_counter()
696
+ logger.debug(f"Requesting {r_type} from {url}")
697
+ r = req(url, timeout=budget.timeout(), **request_kwargs)
698
+ duration = time.perf_counter() - start_time
699
+ logger.debug(f"{url} took {duration:.4f} seconds.")
700
+ if (
701
+ r.status_code != 401
702
+ or loaders is None
703
+ or auth_attempt
704
+ or r_type not in DEFAULT_ALLOWED_METHODS
705
+ ):
706
+ return r
707
+ r.close()
708
+ logger.warning("Error 401; forcing auth refresh once within the request budget")
731
709
 
732
710
  except AuthError as e:
733
711
  logger.warning(f"Auth error for {url}: {e}")
@@ -736,32 +714,15 @@ def make_request(
736
714
  r.error_type = "auth_error"
737
715
  r.status_code = 401
738
716
  r._content = str(e).encode("utf-8")
739
- keep_request = False
740
- break
741
-
742
- except requests.exceptions.ConnectionError:
743
- logger.exception(f"Error connecting {url}")
744
- except TypeError as e:
745
- logger.exception(f"Type error for {url} exception {e}")
746
- raise e
747
- except Exception as e:
748
- logger.exception(f"Error connecting {url} exception {e}")
749
-
750
- counter += 1
751
- if counter >= TRIES:
752
- keep_request = False
717
+ return r
718
+ except requests.RequestException as e:
719
+ logger.warning(f"Request {r_type} to {url} failed: {e}")
753
720
  r = Response()
754
721
  r.code = "expired"
755
722
  r.error_type = "expired"
756
723
  r.status_code = 500
757
- break
758
-
759
- logger.debug(
760
- f"Trying request again after {TIMEOFF}s - Counter: {counter}/{TRIES} - URL: {url}"
761
- )
762
- time.sleep(TIMEOFF)
763
-
764
- return r
724
+ r._content = str(e).encode("utf-8")
725
+ return r
765
726
 
766
727
 
767
728
  def build_session(
@@ -779,23 +740,7 @@ def build_session(
779
740
  if accept_gzip:
780
741
  s.headers.setdefault("Accept-Encoding", "gzip")
781
742
 
782
- retry_kwargs = dict(
783
- total=retries,
784
- connect=retries,
785
- read=retries,
786
- status=retries,
787
- backoff_factor=backoff_factor,
788
- status_forcelist=DEFAULT_STATUS_FORCELIST,
789
- respect_retry_after_header=True,
790
- raise_on_status=False,
791
- )
792
-
793
- try:
794
- retry_cfg = Retry(allowed_methods=DEFAULT_ALLOWED_METHODS, **retry_kwargs)
795
- except TypeError:
796
- retry_cfg = Retry(method_whitelist=DEFAULT_ALLOWED_METHODS, **retry_kwargs)
797
-
798
- adapter = HTTPAdapter(max_retries=retry_cfg)
743
+ adapter = DeadlineHTTPAdapter(retries=retries, backoff_factor=backoff_factor)
799
744
  s.mount("https://", adapter)
800
745
  s.mount("http://", adapter)
801
746
  return s
@@ -929,24 +874,7 @@ def _install_retry_adapters_in_place(
929
874
  Configure retry adapters on an EXISTING session object (do not rebind 'session').
930
875
  This is critical so 'from utils import session' users still get the updated behavior.
931
876
  """
932
- retry_kwargs = dict(
933
- total=retries,
934
- connect=retries,
935
- read=retries,
936
- status=retries,
937
- backoff_factor=backoff_factor,
938
- status_forcelist=DEFAULT_STATUS_FORCELIST,
939
- respect_retry_after_header=True,
940
- raise_on_status=False,
941
- )
942
-
943
- # urllib3 compatibility across versions
944
- try:
945
- retry_cfg = Retry(allowed_methods=DEFAULT_ALLOWED_METHODS, **retry_kwargs)
946
- except TypeError:
947
- retry_cfg = Retry(method_whitelist=DEFAULT_ALLOWED_METHODS, **retry_kwargs)
948
-
949
- adapter = HTTPAdapter(max_retries=retry_cfg)
877
+ adapter = DeadlineHTTPAdapter(retries=retries, backoff_factor=backoff_factor)
950
878
 
951
879
  # Close old adapters' pools (best-effort), then mount new ones
952
880
  for prefix in ("https://", "http://"):