mainsequence 9.0.6.dev39__tar.gz → 9.0.6.dev41__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 (103) hide show
  1. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/PKG-INFO +1 -1
  2. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +10 -2
  3. mainsequence-9.0.6.dev41/mainsequence/client/_transport.py +154 -0
  4. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/client.py +17 -11
  5. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/inference.py +2 -2
  6. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/utils.py +43 -115
  7. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/PKG-INFO +1 -1
  8. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/SOURCES.txt +2 -0
  9. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/pyproject.toml +1 -1
  10. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_auth_bootstrap.py +3 -0
  11. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_auth_precedence.py +3 -0
  12. mainsequence-9.0.6.dev41/tests/test_http_retry_budget.py +434 -0
  13. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/LICENSE +0 -0
  14. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/README.md +0 -0
  15. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/AGENTS.md +0 -0
  16. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  17. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  18. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  19. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  20. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  21. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  22. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  23. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  24. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/__init__.py +0 -0
  25. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/__main__.py +0 -0
  26. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/_request_identity.py +0 -0
  27. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/bootstrap.py +0 -0
  28. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/__init__.py +0 -0
  29. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/api.py +0 -0
  30. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/browser_auth.py +0 -0
  31. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/cli.py +0 -0
  32. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/code_repository_status.py +0 -0
  33. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/config.py +0 -0
  34. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/docker_utils.py +0 -0
  35. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/doctor.py +0 -0
  36. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/local_ops.py +0 -0
  37. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/model_filters.py +0 -0
  38. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/pydantic_cli.py +0 -0
  39. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/sdk_utils.py +0 -0
  40. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/ssh_utils.py +0 -0
  41. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/cli/ui.py +0 -0
  42. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/__init__.py +0 -0
  43. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/agent_runtime_models.py +0 -0
  44. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/base.py +0 -0
  45. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/compute_validation.py +0 -0
  46. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/exceptions.py +0 -0
  47. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/github_issues.py +0 -0
  48. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/models_foundry.py +0 -0
  49. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/models_helpers.py +0 -0
  50. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/models_user.py +0 -0
  51. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/observability.py +0 -0
  52. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/client/value_sets.py +0 -0
  53. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/code_repository_context.py +0 -0
  54. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/code_repository_skills.py +0 -0
  55. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/defaults.py +0 -0
  56. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/instrumentation/__init__.py +0 -0
  57. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/instrumentation/utils.py +0 -0
  58. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/logconf.py +0 -0
  59. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/repository_identity_security.py +0 -0
  60. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/runtime_flags.py +0 -0
  61. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/scaffold_skills.py +0 -0
  62. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/server/__init__.py +0 -0
  63. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/server/caller_assertions.py +0 -0
  64. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence/server/fastapi.py +0 -0
  65. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/dependency_links.txt +0 -0
  66. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/entry_points.txt +0 -0
  67. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/requires.txt +0 -0
  68. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/mainsequence.egg-info/top_level.txt +0 -0
  69. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/setup.cfg +0 -0
  70. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_caller_assertions.py +0 -0
  71. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_canonical_api_route_contract.py +0 -0
  72. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_cli.py +0 -0
  73. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_cli_auth_contract.py +0 -0
  74. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_cli_browser_auth.py +0 -0
  75. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_cli_secure_storage.py +0 -0
  76. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_client.py +0 -0
  77. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_code_repository_branch_contract.py +0 -0
  78. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_code_repository_documentation_contract.py +0 -0
  79. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_code_repository_runtime_context.py +0 -0
  80. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_code_repository_skills.py +0 -0
  81. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_dependency_extras.py +0 -0
  82. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_dev_release_version.py +0 -0
  83. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_environment_resource_context.py +0 -0
  84. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_environment_response_contracts.py +0 -0
  85. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_fastapi_request_identity.py +0 -0
  86. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_filter_normalization.py +0 -0
  87. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_github_issues_client.py +0 -0
  88. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_independent_context.py +0 -0
  89. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_inference_client.py +0 -0
  90. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_instrumentation.py +0 -0
  91. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_job_client.py +0 -0
  92. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_local_ops.py +0 -0
  93. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_logconf.py +0 -0
  94. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_models_user_request_bound_auth.py +0 -0
  95. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_notification_uid_contract.py +0 -0
  96. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_observability_client.py +0 -0
  97. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_scaffold_skills.py +0 -0
  98. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_secret_client_model.py +0 -0
  99. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_source_ownership.py +0 -0
  100. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_ssh_utils.py +0 -0
  101. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_tolerant_response_reading.py +0 -0
  102. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/tests/test_tolerant_value_sets.py +0 -0
  103. {mainsequence-9.0.6.dev39 → mainsequence-9.0.6.dev41}/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.6.dev39
3
+ Version: 9.0.6.dev41
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -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
 
@@ -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:
@@ -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://"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.6.dev39
3
+ Version: 9.0.6.dev41
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -43,6 +43,7 @@ mainsequence/cli/sdk_utils.py
43
43
  mainsequence/cli/ssh_utils.py
44
44
  mainsequence/cli/ui.py
45
45
  mainsequence/client/__init__.py
46
+ mainsequence/client/_transport.py
46
47
  mainsequence/client/agent_runtime_models.py
47
48
  mainsequence/client/base.py
48
49
  mainsequence/client/client.py
@@ -81,6 +82,7 @@ tests/test_environment_response_contracts.py
81
82
  tests/test_fastapi_request_identity.py
82
83
  tests/test_filter_normalization.py
83
84
  tests/test_github_issues_client.py
85
+ tests/test_http_retry_budget.py
84
86
  tests/test_independent_context.py
85
87
  tests/test_inference_client.py
86
88
  tests/test_instrumentation.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.6.dev39"
7
+ version = "9.0.6.dev41"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -286,6 +286,9 @@ def test_request_reports_a_refused_renewal_with_its_reason(refused):
286
286
  status_code = 401
287
287
  text = '{"detail": "Given token not valid for any token type"}'
288
288
 
289
+ def close(self):
290
+ pass
291
+
289
292
  class Session:
290
293
  headers: dict = {}
291
294
 
@@ -76,6 +76,9 @@ class _FakeResponse:
76
76
  def json(self):
77
77
  return self._payload
78
78
 
79
+ def close(self):
80
+ pass
81
+
79
82
 
80
83
  def _jwt_with_exp(exp: int) -> str:
81
84
  header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()