mainsequence 9.0.21.dev63__tar.gz → 9.0.22.dev65__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 (106) hide show
  1. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/PKG-INFO +1 -1
  2. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/api.py +22 -2
  3. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/cli.py +4 -0
  4. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/agent_runtime_models.py +1 -1
  5. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/utils.py +4 -4
  6. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/logconf.py +38 -10
  7. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/PKG-INFO +1 -1
  8. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/SOURCES.txt +2 -1
  9. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/pyproject.toml +1 -1
  10. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_auth_precedence.py +13 -5
  11. mainsequence-9.0.22.dev65/tests/test_cli_request_budget.py +232 -0
  12. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_code_repository_skills.py +1 -1
  13. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_http_retry_budget.py +1 -1
  14. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_logconf.py +120 -5
  15. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_runtime_credential_exchange.py +4 -4
  16. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/LICENSE +0 -0
  17. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/README.md +0 -0
  18. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/AGENTS.md +0 -0
  19. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  20. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  21. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  22. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  23. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  24. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  25. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  26. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  27. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  28. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/__init__.py +0 -0
  29. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/__main__.py +0 -0
  30. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/_request_identity.py +0 -0
  31. {mainsequence-9.0.21.dev63/mainsequence/client → mainsequence-9.0.22.dev65/mainsequence}/_transport.py +0 -0
  32. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/bootstrap.py +0 -0
  33. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/__init__.py +0 -0
  34. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/browser_auth.py +0 -0
  35. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/code_repository_status.py +0 -0
  36. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/config.py +0 -0
  37. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/docker_utils.py +0 -0
  38. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/doctor.py +0 -0
  39. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/local_ops.py +0 -0
  40. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/model_filters.py +0 -0
  41. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/pydantic_cli.py +0 -0
  42. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/sdk_utils.py +0 -0
  43. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/ssh_utils.py +0 -0
  44. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/cli/ui.py +0 -0
  45. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/__init__.py +0 -0
  46. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/base.py +0 -0
  47. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/client.py +0 -0
  48. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/compute_validation.py +0 -0
  49. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/exceptions.py +0 -0
  50. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/github_issues.py +0 -0
  51. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/inference.py +0 -0
  52. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/models_foundry.py +0 -0
  53. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/models_helpers.py +0 -0
  54. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/models_user.py +0 -0
  55. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/observability.py +0 -0
  56. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/client/value_sets.py +0 -0
  57. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/code_repository_context.py +0 -0
  58. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/code_repository_skills.py +0 -0
  59. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/defaults.py +0 -0
  60. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/instrumentation/__init__.py +0 -0
  61. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/instrumentation/utils.py +0 -0
  62. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/repository_identity_security.py +0 -0
  63. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/runtime_credential_exchange.py +0 -0
  64. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/runtime_flags.py +0 -0
  65. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/scaffold_skills.py +0 -0
  66. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/server/__init__.py +0 -0
  67. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/server/caller_assertions.py +0 -0
  68. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence/server/fastapi.py +0 -0
  69. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/dependency_links.txt +0 -0
  70. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/entry_points.txt +0 -0
  71. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/requires.txt +0 -0
  72. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/mainsequence.egg-info/top_level.txt +0 -0
  73. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/setup.cfg +0 -0
  74. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_auth_bootstrap.py +0 -0
  75. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_caller_assertions.py +0 -0
  76. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_canonical_api_route_contract.py +0 -0
  77. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_cli_auth_contract.py +0 -0
  78. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_cli_browser_auth.py +0 -0
  79. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_cli_secure_storage.py +0 -0
  80. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_client.py +0 -0
  81. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_code_repository_branch_contract.py +0 -0
  82. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_code_repository_documentation_contract.py +0 -0
  83. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_code_repository_runtime_context.py +0 -0
  84. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_credential_repr.py +0 -0
  85. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_dependency_extras.py +0 -0
  86. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_dev_release_version.py +0 -0
  87. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_environment_resource_context.py +0 -0
  88. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_environment_response_contracts.py +0 -0
  89. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_fastapi_mcp.py +0 -0
  90. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_fastapi_request_identity.py +0 -0
  91. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_github_issues_client.py +0 -0
  92. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_independent_context.py +0 -0
  93. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_inference_client.py +0 -0
  94. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_instrumentation.py +0 -0
  95. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_job_client.py +0 -0
  96. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_local_ops.py +0 -0
  97. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_models_user_request_bound_auth.py +0 -0
  98. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_notification_uid_contract.py +0 -0
  99. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_observability_client.py +0 -0
  100. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_scaffold_skills.py +0 -0
  101. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_secret_client_model.py +0 -0
  102. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_source_ownership.py +0 -0
  103. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_ssh_utils.py +0 -0
  104. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_tolerant_response_reading.py +0 -0
  105. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/tests/test_tolerant_value_sets.py +0 -0
  106. {mainsequence-9.0.21.dev63 → mainsequence-9.0.22.dev65}/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.21.dev63
3
+ Version: 9.0.22.dev65
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -63,7 +63,27 @@ MCP_PROTOCOL_VERSION = "2025-11-25"
63
63
  MCP_CLIENT_NAME = "mainsequence-cli"
64
64
  MCP_MAX_RESOURCE_PAGES = 100
65
65
 
66
- S = requests.Session()
66
+
67
+ class _CliSession(requests.Session):
68
+ """The CLI's HTTP session.
69
+
70
+ A request sent without its own timeout gets the SDK's default request
71
+ budget: 5 s to connect, 120 s to read and 125 s in total, inside any budget
72
+ that is already running.
73
+ """
74
+
75
+ def request(self, method, url, **kwargs):
76
+ if kwargs.get("timeout") is not None:
77
+ return super().request(method, url, **kwargs)
78
+ # The CLI imports the SDK client on its first request, not when it starts.
79
+ from mainsequence._transport import DEFAULT_TIMEOUT, request_budget
80
+
81
+ with request_budget(DEFAULT_TIMEOUT) as budget:
82
+ kwargs["timeout"] = budget.timeout()
83
+ return super().request(method, url, **kwargs)
84
+
85
+
86
+ S = _CliSession()
67
87
  S.headers.update({"Content-Type": "application/json"})
68
88
 
69
89
 
@@ -494,7 +514,7 @@ def _mcp_json_rpc(
494
514
  default request budget: 5 s to connect, 120 s to read, 125 s in total.
495
515
  """
496
516
  # The CLI imports the SDK client only where it needs it.
497
- from mainsequence.client._transport import DEFAULT_TIMEOUT, request_budget
517
+ from mainsequence._transport import DEFAULT_TIMEOUT, request_budget
498
518
 
499
519
  payload = {
500
520
  "jsonrpc": "2.0",
@@ -1946,6 +1946,10 @@ def login(
1946
1946
  except ApiError as e:
1947
1947
  error(f"Login failed: {e}")
1948
1948
  raise typer.Exit(1) from e
1949
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
1950
+ # Never echo the exception: a transport error can carry request details.
1951
+ error(f"The backend at {normalized_backend} could not be reached ({type(e).__name__}).")
1952
+ raise typer.Exit(1) from e
1949
1953
  finally:
1950
1954
  if previous_backend_override is None:
1951
1955
  os.environ.pop("MAINSEQUENCE_ENDPOINT", None)
@@ -12,7 +12,7 @@ from typing import Any, ClassVar, Literal
12
12
  import requests
13
13
  from pydantic import ConfigDict, Field, model_validator
14
14
 
15
- from ._transport import request_budget, request_within_budget
15
+ from .._transport import request_budget, request_within_budget
16
16
  from .base import (
17
17
  BaseObjectOrm,
18
18
  BasePydanticModel,
@@ -28,10 +28,10 @@ from mainsequence.runtime_credential_exchange import (
28
28
  identity_token_file_from_environment,
29
29
  )
30
30
 
31
- from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
32
- from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
33
- from ._transport import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
34
- from ._transport import DeadlineHTTPAdapter, remaining_timeout, request_budget
31
+ from .._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
32
+ from .._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
33
+ from .._transport import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
34
+ from .._transport import DeadlineHTTPAdapter, remaining_timeout, request_budget
35
35
 
36
36
  # ---- Backend defaults (single source of truth) ----
37
37
  MAINSEQUENCE_ENDPOINT = resolve_backend_endpoint()
@@ -5,7 +5,6 @@ import logging
5
5
  import logging.config
6
6
  import os
7
7
  import sys
8
- import time
9
8
  import traceback
10
9
  from collections.abc import Mapping
11
10
  from importlib.metadata import PackageNotFoundError
@@ -20,6 +19,7 @@ from structlog.contextvars import bind_contextvars, unbind_contextvars
20
19
  from structlog.dev import ConsoleRenderer
21
20
  from structlog.stdlib import BoundLogger
22
21
 
22
+ from ._transport import DeadlineHTTPAdapter, request_budget
23
23
  from .defaults import resolve_backend_endpoint
24
24
  from .instrumentation import OTelJSONRenderer
25
25
  from .repository_identity_security import UNSUPPORTED_SOURCE_IDENTITY_ENV_NAMES
@@ -76,14 +76,27 @@ class CustomConsoleRenderer(ConsoleRenderer):
76
76
  return rendered
77
77
 
78
78
 
79
- def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
79
+ def _request_job_startup_state(*, timeout_s: float | None = None) -> dict[str, Any]:
80
80
  """
81
81
  Fetch startup state from backend using current env vars (JWT auth, endpoint, job_run_uid).
82
82
  Safe to call later after auth when access/refresh tokens become available.
83
+
84
+ The whole read, token renewal included, ends within one request budget:
85
+ ``timeout_s`` seconds in total, or the SDK's default request budget when it is
86
+ None. The GET is retried like any other SDK read. The startup state only
87
+ enriches the logger, so when the backend cannot be reached or does not answer
88
+ in time this reports it on stderr and returns an empty state instead of
89
+ failing the import.
83
90
  """
84
91
  if not is_running_in_pod():
85
92
  return {}
86
93
 
94
+ with request_budget(timeout_s) as budget:
95
+ return _read_job_startup_state(budget)
96
+
97
+
98
+ def _read_job_startup_state(budget) -> dict[str, Any]:
99
+
87
100
  auth_mode = (os.getenv("MAINSEQUENCE_AUTH_MODE") or "jwt").strip().lower()
88
101
 
89
102
  def _backend_base_url() -> str:
@@ -110,7 +123,7 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
110
123
  token_url,
111
124
  headers={"Content-Type": "application/json"},
112
125
  json=body,
113
- timeout=timeout_s,
126
+ timeout=budget.timeout(),
114
127
  allow_redirects=False,
115
128
  )
116
129
 
@@ -119,7 +132,7 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
119
132
  _post,
120
133
  credential_id=(os.getenv(RUNTIME_CREDENTIAL_ID_ENV) or "").strip(),
121
134
  identity_token_file=identity_token_file_from_environment(),
122
- deadline=time.monotonic() + timeout_s,
135
+ deadline=budget.deadline,
123
136
  )
124
137
  except Exception:
125
138
  return False
@@ -155,7 +168,7 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
155
168
  f"{_backend_base_url()}/auth/jwt-token/token/refresh/",
156
169
  headers={"Content-Type": "application/json"},
157
170
  json={"refresh": refresh_token},
158
- timeout=timeout_s,
171
+ timeout=budget.timeout(),
159
172
  )
160
173
  except Exception:
161
174
  return False
@@ -202,10 +215,25 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
202
215
  endpoint = f"{_backend_base_url()}/api/v1/job-runs/{job_run_uid}/startup-state/"
203
216
  params: dict[str, Any] = {}
204
217
 
205
- resp = requests.get(endpoint, headers=headers, params=params, timeout=timeout_s)
206
- if resp.status_code in {401, 403} and using_jwt and _refresh_access_token():
207
- headers, _ = _auth_headers()
208
- resp = requests.get(endpoint, headers=headers, params=params, timeout=timeout_s)
218
+ try:
219
+ with requests.Session() as session:
220
+ adapter = DeadlineHTTPAdapter(retries=3, backoff_factor=0.5)
221
+ session.mount("https://", adapter)
222
+ session.mount("http://", adapter)
223
+ resp = session.get(endpoint, headers=headers, params=params, timeout=budget.timeout())
224
+ if resp.status_code in {401, 403} and using_jwt and _refresh_access_token():
225
+ headers, _ = _auth_headers()
226
+ resp = session.get(
227
+ endpoint, headers=headers, params=params, timeout=budget.timeout()
228
+ )
229
+ except requests.RequestException as exc:
230
+ # The exception text is not echoed: it can carry the request URL and headers.
231
+ print(
232
+ "mainsequence: the Job startup state could not be read "
233
+ f"({type(exc).__name__}); continuing without it.",
234
+ file=sys.stderr,
235
+ )
236
+ return {}
209
237
 
210
238
  if resp.status_code != 200:
211
239
  # Do not spam stdout during CLI usage when auth context is not present yet.
@@ -426,7 +454,7 @@ def build_application_logger(application_name: str = "ms-sdk", **metadata):
426
454
  return logger
427
455
 
428
456
 
429
- def refresh_application_logger_bindings(*, timeout_s: float = 10.0) -> BoundLogger:
457
+ def refresh_application_logger_bindings(*, timeout_s: float | None = None) -> BoundLogger:
430
458
  """
431
459
  Importable helper: call this AFTER the user authenticates / token exists.
432
460
  It re-fetches backend state and binds the same keys as build_application_logger().
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.21.dev63
3
+ Version: 9.0.22.dev65
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -14,6 +14,7 @@ agent_scaffold/skills/sdk_code_repository_execution/SKILL.md
14
14
  mainsequence/__init__.py
15
15
  mainsequence/__main__.py
16
16
  mainsequence/_request_identity.py
17
+ mainsequence/_transport.py
17
18
  mainsequence/bootstrap.py
18
19
  mainsequence/code_repository_context.py
19
20
  mainsequence/code_repository_skills.py
@@ -44,7 +45,6 @@ mainsequence/cli/sdk_utils.py
44
45
  mainsequence/cli/ssh_utils.py
45
46
  mainsequence/cli/ui.py
46
47
  mainsequence/client/__init__.py
47
- mainsequence/client/_transport.py
48
48
  mainsequence/client/agent_runtime_models.py
49
49
  mainsequence/client/base.py
50
50
  mainsequence/client/client.py
@@ -69,6 +69,7 @@ tests/test_caller_assertions.py
69
69
  tests/test_canonical_api_route_contract.py
70
70
  tests/test_cli_auth_contract.py
71
71
  tests/test_cli_browser_auth.py
72
+ tests/test_cli_request_budget.py
72
73
  tests/test_cli_secure_storage.py
73
74
  tests/test_client.py
74
75
  tests/test_code_repository_branch_contract.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.21.dev63"
7
+ version = "9.0.22.dev65"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -22,6 +22,12 @@ _UNSUPPORTED_ENVIRONMENT_UID_ENV = (
22
22
  )
23
23
 
24
24
 
25
+ def _stub_startup_state_get(monkeypatch, fake_get):
26
+ monkeypatch.setattr(
27
+ requests.Session, "get", lambda _session, url, **kwargs: fake_get(url, **kwargs)
28
+ )
29
+
30
+
25
31
  class _FakeResponse:
26
32
  def __init__(self, status_code: int, payload: dict | None = None):
27
33
  self.status_code = status_code
@@ -107,7 +113,7 @@ def test_logconf_refreshes_jwt_before_startup_state_request(monkeypatch):
107
113
  post_calls.append({"url": url, **kwargs})
108
114
  return _FakeResponse(200, {"access": "jwt-access"})
109
115
 
110
- monkeypatch.setattr(requests, "get", _fake_get)
116
+ _stub_startup_state_get(monkeypatch, _fake_get)
111
117
  monkeypatch.setattr(requests, "post", _fake_post)
112
118
 
113
119
  _load_mainsequence_submodule("mainsequence.logconf")
@@ -143,11 +149,14 @@ def test_logconf_runtime_credential_exchanges_before_startup_state_request(monke
143
149
  },
144
150
  )
145
151
 
146
- monkeypatch.setattr(requests, "get", _fake_get)
152
+ _stub_startup_state_get(monkeypatch, _fake_get)
147
153
  monkeypatch.setattr(requests, "post", _fake_post)
148
154
 
149
155
  _load_mainsequence_submodule("mainsequence.logconf")
150
156
 
157
+ # The exchange receives what is left of the startup-state read's request budget.
158
+ timeout = post_calls[0].pop("timeout")
159
+ assert 0 < timeout.total <= 125.0
151
160
  assert post_calls == [
152
161
  {
153
162
  "url": "https://backend.example/api/v1/runtime-credentials/token/",
@@ -156,7 +165,6 @@ def test_logconf_runtime_credential_exchanges_before_startup_state_request(monke
156
165
  "credential_id": "cred-id",
157
166
  "workload_identity_token": "projected-token",
158
167
  },
159
- "timeout": 10.0,
160
168
  "allow_redirects": False,
161
169
  }
162
170
  ]
@@ -192,7 +200,7 @@ def test_logconf_runtime_credential_retries_after_auth_failure(monkeypatch, tmp_
192
200
  },
193
201
  )
194
202
 
195
- monkeypatch.setattr(requests, "get", _fake_get)
203
+ _stub_startup_state_get(monkeypatch, _fake_get)
196
204
  monkeypatch.setattr(requests, "post", _fake_post)
197
205
 
198
206
  _load_mainsequence_submodule("mainsequence.logconf")
@@ -594,7 +602,7 @@ def test_logconf_session_jwt_does_not_refresh(monkeypatch):
594
602
  post_calls.append({"url": url, **kwargs})
595
603
  return _FakeResponse(200, {"access": "unexpected"})
596
604
 
597
- monkeypatch.setattr(requests, "get", _fake_get)
605
+ _stub_startup_state_get(monkeypatch, _fake_get)
598
606
  monkeypatch.setattr(requests, "post", _fake_post)
599
607
 
600
608
  _load_mainsequence_submodule("mainsequence.logconf")
@@ -0,0 +1,232 @@
1
+ """Every CLI request ends within the SDK's default request budget.
2
+
3
+ The tests cross the real HTTP boundary with a local server. A short stand-in for
4
+ the default budget (5 s connect, 120 s read, 125 s total) keeps them fast: the
5
+ CLI reads the default when each request starts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from contextlib import ExitStack
13
+
14
+ import pytest
15
+ import requests
16
+ from typer.testing import CliRunner
17
+
18
+ from mainsequence import _transport as transport
19
+ from mainsequence.cli import api as cli_api
20
+ from mainsequence.cli import cli as cli_mod
21
+ from tests._http_server import answer_json, answer_sequence, never_answer, serve
22
+
23
+ runner = CliRunner()
24
+
25
+ TOKEN_PATH = "/auth/cli/token/"
26
+ HANDOFF_START_PATH = "/auth/mcp/cli-handoff/start/"
27
+ HANDOFF_CALLBACK_PATH = "/auth/mcp/cli-handoff/callback/"
28
+ LOGOUT_PATH = "/auth/jwt-token/logout/"
29
+ REVOKE_PATH = "/auth/cli/revoke/"
30
+ REFRESH_PATH = "/auth/jwt-token/token/refresh/"
31
+ ME_PATH = "/api/v1/users/me/"
32
+
33
+
34
+ @pytest.fixture
35
+ def cli_backend(monkeypatch):
36
+ """Serve routes as the CLI's backend, with a saved session; return (backend, calls)."""
37
+ monkeypatch.delenv("MAINSEQUENCE_AUTH_MODE", raising=False)
38
+ monkeypatch.setattr(cli_api, "_access_token", lambda: "saved-jwt")
39
+ monkeypatch.setattr(cli_api, "_refresh_token", lambda: "saved-refresh")
40
+ with ExitStack() as servers:
41
+
42
+ def start(routes):
43
+ url, calls = servers.enter_context(serve(routes))
44
+ backend = url.rstrip("/")
45
+ monkeypatch.setattr(cli_api, "backend_url", lambda: backend)
46
+ monkeypatch.setattr(cli_mod.cfg, "backend_url", lambda: backend)
47
+ return backend, calls
48
+
49
+ yield start
50
+
51
+
52
+ @pytest.fixture
53
+ def short_default_budget(monkeypatch):
54
+ monkeypatch.setattr(transport, "DEFAULT_TIMEOUT", (0.2, 0.3))
55
+
56
+
57
+ def _sent(calls):
58
+ return [(method, path) for method, path, _body in calls]
59
+
60
+
61
+ @pytest.mark.parametrize(
62
+ ("method", "path", "call"),
63
+ [
64
+ (
65
+ "POST",
66
+ TOKEN_PATH,
67
+ lambda backend: cli_api.exchange_cli_authorization_code(
68
+ code="code", code_verifier="verifier", redirect_uri="http://127.0.0.1/callback"
69
+ ),
70
+ ),
71
+ (
72
+ "POST",
73
+ HANDOFF_START_PATH,
74
+ lambda backend: cli_api.start_mcp_cli_handoff(state="state", code_challenge="c"),
75
+ ),
76
+ (
77
+ "POST",
78
+ HANDOFF_CALLBACK_PATH,
79
+ lambda backend: cli_api.poll_mcp_cli_handoff(
80
+ redirect_uri=f"{backend}{HANDOFF_CALLBACK_PATH}",
81
+ handoff_uid="handoff",
82
+ state="state",
83
+ code_verifier="verifier",
84
+ ),
85
+ ),
86
+ ("POST", REFRESH_PATH, lambda backend: cli_api.refresh_access()),
87
+ ("GET", ME_PATH, lambda backend: cli_api.authed("GET", ME_PATH)),
88
+ ],
89
+ ids=["login-code-exchange", "mcp-handoff-start", "mcp-handoff-poll", "refresh", "authed"],
90
+ )
91
+ def test_request_to_a_backend_that_never_answers_fails_within_the_default_budget(
92
+ cli_backend, short_default_budget, method, path, call
93
+ ):
94
+ backend, calls = cli_backend({(method, path): never_answer()})
95
+ started = time.monotonic()
96
+ with pytest.raises(requests.Timeout):
97
+ call(backend)
98
+ elapsed = time.monotonic() - started
99
+
100
+ assert elapsed < 0.8
101
+ assert _sent(calls) == [(method, path)]
102
+
103
+
104
+ def test_logout_ends_within_the_default_budget_when_the_backend_never_answers(
105
+ cli_backend, short_default_budget
106
+ ):
107
+ backend, calls = cli_backend(
108
+ {("POST", REVOKE_PATH): never_answer(), ("POST", LOGOUT_PATH): never_answer()}
109
+ )
110
+ started = time.monotonic()
111
+ revoke = cli_api.logout_cli_session()
112
+ logout = cli_api.logout_jwt_session()
113
+ elapsed = time.monotonic() - started
114
+
115
+ assert elapsed < 1.6
116
+ assert revoke["attempted"] is True
117
+ assert revoke["revoked"] is False
118
+ assert revoke["method"] == "error"
119
+ assert logout is False
120
+ assert _sent(calls) == [("POST", REVOKE_PATH), ("POST", LOGOUT_PATH)]
121
+
122
+
123
+ def test_token_renewal_inside_an_mcp_request_is_bounded_too(cli_backend, short_default_budget):
124
+ backend, calls = cli_backend(
125
+ {
126
+ ("POST", "/mcp"): answer_json(401, {"detail": "expired"}),
127
+ ("POST", REFRESH_PATH): never_answer(),
128
+ }
129
+ )
130
+ started = time.monotonic()
131
+ with pytest.raises(cli_api.ApiError, match="MCP resources/list request timed out"):
132
+ cli_api._mcp_json_rpc("resources/list", {}, request_id=7)
133
+ elapsed = time.monotonic() - started
134
+
135
+ assert elapsed < 0.8
136
+ assert _sent(calls) == [("POST", "/mcp"), ("POST", REFRESH_PATH)]
137
+
138
+
139
+ def test_authenticated_command_reports_a_backend_that_never_answers(
140
+ cli_backend, short_default_budget
141
+ ):
142
+ backend, calls = cli_backend({("GET", ME_PATH): never_answer()})
143
+
144
+ result = runner.invoke(cli_mod.app, ["code-repository", "list"])
145
+
146
+ assert result.exit_code == 1
147
+ assert isinstance(result.exception, SystemExit)
148
+ assert result.output == f"The backend at {backend} could not be reached (ReadTimeout).\n"
149
+ assert _sent(calls) == [("GET", ME_PATH)]
150
+
151
+
152
+ @pytest.mark.parametrize(
153
+ "failure", [requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout]
154
+ )
155
+ def test_login_says_when_the_backend_could_not_be_reached(monkeypatch, failure):
156
+ monkeypatch.delenv("MAINSEQUENCE_AUTH_MODE", raising=False)
157
+ monkeypatch.setattr(cli_mod.cfg, "backend_url", lambda: "https://backend.example")
158
+
159
+ def unreachable(**_kwargs):
160
+ raise failure("HTTPSConnectionPool(host='backend.example', port=443): Read timed out.")
161
+
162
+ monkeypatch.setattr(cli_mod, "login_via_browser", unreachable)
163
+
164
+ result = runner.invoke(cli_mod.app, ["login", "--no-status"])
165
+
166
+ assert result.exit_code == 1
167
+ # The command ended itself; an exception that escapes it is what prints a traceback.
168
+ assert isinstance(result.exception, SystemExit)
169
+ assert (
170
+ f"The backend at https://backend.example could not be reached ({failure.__name__})."
171
+ in result.output
172
+ )
173
+ assert "HTTPSConnectionPool" not in result.output
174
+
175
+
176
+ def test_refresh_and_the_authed_retry_work_against_a_responsive_backend(cli_backend, monkeypatch):
177
+ saved = []
178
+ monkeypatch.setattr(cli_api, "get_tokens", lambda: {"username": "user@example.com"})
179
+ monkeypatch.setattr(cli_api, "save_tokens", lambda *args: saved.append(args) or True)
180
+ backend, calls = cli_backend(
181
+ {
182
+ ("GET", ME_PATH): answer_sequence(
183
+ answer_json(401, {"detail": "expired"}),
184
+ answer_json(200, {"user": {"username": "user@example.com"}}),
185
+ ),
186
+ ("POST", REFRESH_PATH): answer_json(
187
+ 200, {"access": "renewed-jwt", "refresh": "renewed-refresh"}
188
+ ),
189
+ }
190
+ )
191
+
192
+ response = cli_api.authed("GET", ME_PATH)
193
+
194
+ assert response.status_code == 200
195
+ assert response.json() == {"user": {"username": "user@example.com"}}
196
+ assert _sent(calls) == [("GET", ME_PATH), ("POST", REFRESH_PATH), ("GET", ME_PATH)]
197
+ assert json.loads(calls[1][2]) == {"refresh": "saved-refresh"}
198
+ assert saved == [("user@example.com", "renewed-jwt", "renewed-refresh")]
199
+
200
+
201
+ def test_login_code_exchange_works_against_a_responsive_backend(cli_backend):
202
+ backend, calls = cli_backend(
203
+ {("POST", TOKEN_PATH): answer_json(200, {"access": "jwt", "refresh": "refresh"})}
204
+ )
205
+
206
+ tokens = cli_api.exchange_cli_authorization_code(
207
+ code="code", code_verifier="verifier", redirect_uri="http://127.0.0.1/callback"
208
+ )
209
+
210
+ assert tokens == {"backend": backend, "access": "jwt", "refresh": "refresh"}
211
+ assert json.loads(calls[0][2]) == {
212
+ "client_id": cli_api.CLI_BROWSER_CLIENT_ID,
213
+ "code": "code",
214
+ "code_verifier": "verifier",
215
+ "redirect_uri": "http://127.0.0.1/callback",
216
+ }
217
+
218
+
219
+ def test_logout_revoke_works_against_a_responsive_backend(cli_backend):
220
+ backend, calls = cli_backend(
221
+ {("POST", REVOKE_PATH): answer_json(200, {"detail": "CLI refresh token revoked."})}
222
+ )
223
+
224
+ result = cli_api.logout_cli_session()
225
+
226
+ assert result == {
227
+ "attempted": True,
228
+ "revoked": True,
229
+ "method": "cli_revoke",
230
+ "detail": "CLI refresh token revoked.",
231
+ }
232
+ assert json.loads(calls[0][2]) == {"refresh": "saved-refresh"}
@@ -8,9 +8,9 @@ from pathlib import Path
8
8
 
9
9
  import pytest
10
10
 
11
+ from mainsequence import _transport as transport
11
12
  from mainsequence.cli import api as cli_api
12
13
  from mainsequence.cli.api import ApiError
13
- from mainsequence.client import _transport as transport
14
14
  from mainsequence.code_repository_skills import (
15
15
  PLATFORM_ONTOLOGY_URI,
16
16
  PLATFORM_SKILL_URI_PREFIX,
@@ -7,7 +7,7 @@ from unittest.mock import Mock, PropertyMock
7
7
  import pytest
8
8
  import requests
9
9
 
10
- from mainsequence.client import _transport as transport
10
+ from mainsequence import _transport as transport
11
11
  from mainsequence.client import utils
12
12
  from mainsequence.client.client import MainSequenceClient, MainSequenceClientConfig
13
13
  from mainsequence.client.exceptions import (
@@ -1,10 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import sys
5
+ import time
4
6
 
5
7
  import pytest
6
8
  import requests
9
+ from urllib3.util import Timeout
7
10
 
11
+ from tests._http_server import answer_after, answer_json, answer_sequence, never_answer, serve
8
12
  from tests._support import RETIRED_RUNTIME_CONTEXT_ENV_NAMES as _RUNTIME_CONTEXT_ENV_NAMES
9
13
  from tests._support import load_sdk_submodule as _load_mainsequence_submodule
10
14
 
@@ -40,9 +44,9 @@ def test_logconf_import_skips_job_startup_state_request_outside_pod(monkeypatch)
40
44
 
41
45
  def _fake_get(*args, **kwargs):
42
46
  calls.append((args, kwargs))
43
- raise AssertionError("requests.get should not be called outside pod runtime")
47
+ raise AssertionError("the startup state should not be read outside pod runtime")
44
48
 
45
- monkeypatch.setattr(requests, "get", _fake_get)
49
+ monkeypatch.setattr(requests.Session, "get", lambda _s, *a, **kw: _fake_get(*a, **kw))
46
50
 
47
51
  logconf = _load_mainsequence_submodule("mainsequence.logconf")
48
52
 
@@ -92,9 +96,9 @@ def test_logconf_import_skips_job_startup_state_request_without_job_run_uid(monk
92
96
 
93
97
  def _fake_get(*args, **kwargs):
94
98
  calls.append((args, kwargs))
95
- raise AssertionError("requests.get should not be called without JOB_RUN_UID")
99
+ raise AssertionError("the startup state should not be read without JOB_RUN_UID")
96
100
 
97
- monkeypatch.setattr(requests, "get", _fake_get)
101
+ monkeypatch.setattr(requests.Session, "get", lambda _s, *a, **kw: _fake_get(*a, **kw))
98
102
 
99
103
  logconf = _load_mainsequence_submodule("mainsequence.logconf")
100
104
 
@@ -132,7 +136,7 @@ def test_logconf_import_requests_job_run_detail_startup_state(monkeypatch):
132
136
  )
133
137
  return _FakeResponse()
134
138
 
135
- monkeypatch.setattr(requests, "get", _fake_get)
139
+ monkeypatch.setattr(requests.Session, "get", lambda _s, *a, **kw: _fake_get(*a, **kw))
136
140
 
137
141
  logconf = _load_mainsequence_submodule("mainsequence.logconf")
138
142
 
@@ -147,3 +151,114 @@ def test_logconf_import_requests_job_run_detail_startup_state(monkeypatch):
147
151
  assert bindings["job_run_uid"] == "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
148
152
  assert "project_id" not in bindings
149
153
  assert "data_source_id" not in bindings
154
+
155
+
156
+ # The Job startup-state read (#215) crosses the real HTTP boundary with a local
157
+ # server. A short stand-in for the SDK's default request budget keeps it fast.
158
+
159
+ JOB_RUN_UID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
160
+ STARTUP_STATE_PATH = f"/api/v1/job-runs/{JOB_RUN_UID}/startup-state/"
161
+ REFRESH_PATH = "/auth/jwt-token/token/refresh/"
162
+
163
+
164
+ @pytest.fixture
165
+ def job_logconf(monkeypatch):
166
+ """Load logconf outside a Job, then return it with the Job's environment set."""
167
+ for name in ("MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN", "JOB_RUN_UID"):
168
+ monkeypatch.delenv(name, raising=False)
169
+ logconf = _load_mainsequence_submodule("mainsequence.logconf")
170
+ transport = sys.modules["mainsequence._transport"]
171
+
172
+ def start(base_url, *, auth_mode="session_jwt", default_timeout=(0.2, 0.3)):
173
+ monkeypatch.setattr(transport, "DEFAULT_TIMEOUT", default_timeout)
174
+ monkeypatch.setenv("MAINSEQUENCE_ENDPOINT", base_url.rstrip("/"))
175
+ monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", auth_mode)
176
+ monkeypatch.setenv("MAINSEQUENCE_ACCESS_TOKEN", "secret-access-token")
177
+ monkeypatch.setenv("JOB_RUN_UID", JOB_RUN_UID)
178
+ return logconf
179
+
180
+ return start
181
+
182
+
183
+ def _startup_gets(calls):
184
+ return [call for call in calls if call[:2] == ("GET", STARTUP_STATE_PATH)]
185
+
186
+
187
+ def test_logger_builds_when_the_startup_state_read_times_out(job_logconf, capsys):
188
+ with serve({("GET", STARTUP_STATE_PATH): never_answer()}) as (url, calls):
189
+ logconf = job_logconf(url)
190
+ started = time.monotonic()
191
+ logger = logconf.build_application_logger()
192
+ elapsed = time.monotonic() - started
193
+
194
+ assert elapsed < 0.5 + 0.3
195
+ assert len(_startup_gets(calls)) == 1
196
+ bound = logconf.dump_structlog_bound_logger(logger)["bound_context"]
197
+ assert "job_run_uid" not in bound
198
+ err = capsys.readouterr().err
199
+ assert "the Job startup state could not be read (ReadTimeout)" in err
200
+ assert "secret-access-token" not in err
201
+
202
+
203
+ def test_logger_builds_when_the_backend_cannot_be_reached(job_logconf, capsys):
204
+ with serve({}) as (url, _calls):
205
+ pass # the port is closed again once the server stops
206
+ logconf = job_logconf(url)
207
+
208
+ logger = logconf.build_application_logger()
209
+
210
+ assert logger is not None
211
+ assert "could not be read (ConnectionError)" in capsys.readouterr().err
212
+
213
+
214
+ def test_startup_state_read_retries_a_failed_safe_read_within_its_budget(job_logconf):
215
+ state = {"job_run_uid": JOB_RUN_UID, "command_id": 12}
216
+ route = answer_sequence(
217
+ answer_after(1.0, 200, {}), answer_json(503, {}), answer_json(200, state)
218
+ )
219
+ with serve({("GET", STARTUP_STATE_PATH): route}) as (url, calls):
220
+ logconf = job_logconf(url, default_timeout=Timeout(connect=0.3, read=0.3, total=3.0))
221
+ logger = logconf.build_application_logger()
222
+
223
+ assert len(_startup_gets(calls)) == 3
224
+ bound = logconf.dump_structlog_bound_logger(logger)["bound_context"]
225
+ assert bound["job_run_uid"] == JOB_RUN_UID
226
+ assert bound["command_id"] == 12
227
+
228
+
229
+ def test_denied_startup_state_is_not_treated_as_granted(job_logconf, capsys):
230
+ route = answer_json(403, {"detail": "denied"})
231
+ with serve({("GET", STARTUP_STATE_PATH): route}) as (url, calls):
232
+ logconf = job_logconf(url)
233
+ assert logconf._request_job_startup_state() == {}
234
+
235
+ assert len(_startup_gets(calls)) == 1
236
+ assert capsys.readouterr().err == ""
237
+
238
+
239
+ def test_failed_token_renewal_after_a_denial_shares_the_budget(job_logconf, monkeypatch):
240
+ routes = {
241
+ ("GET", STARTUP_STATE_PATH): answer_json(401, {}),
242
+ ("POST", REFRESH_PATH): never_answer(),
243
+ }
244
+ with serve(routes) as (url, calls):
245
+ logconf = job_logconf(url, auth_mode="jwt")
246
+ monkeypatch.setenv("MAINSEQUENCE_REFRESH_TOKEN", "secret-refresh-token")
247
+ started = time.monotonic()
248
+ assert logconf._request_job_startup_state() == {}
249
+ elapsed = time.monotonic() - started
250
+
251
+ assert elapsed < 0.5 + 0.3
252
+ assert [call[:2] for call in calls] == [("GET", STARTUP_STATE_PATH), ("POST", REFRESH_PATH)]
253
+
254
+
255
+ def test_refreshing_logger_bindings_honours_an_explicit_total_timeout(job_logconf, capsys):
256
+ with serve({("GET", STARTUP_STATE_PATH): never_answer()}) as (url, _calls):
257
+ logconf = job_logconf(url, default_timeout=(5.0, 120.0))
258
+ started = time.monotonic()
259
+ logger = logconf.refresh_application_logger_bindings(timeout_s=0.3)
260
+ elapsed = time.monotonic() - started
261
+
262
+ assert logger is not None
263
+ assert elapsed < 0.3 + 0.3
264
+ assert "could not be read (ReadTimeout)" in capsys.readouterr().err
@@ -138,7 +138,7 @@ def test_no_code_path_reads_the_secret_variable(monkeypatch, runtime):
138
138
  # os.getenv() reads os.environ, so every lookup by name goes through the recorder.
139
139
  monkeypatch.setattr(os, "environ", environ)
140
140
  exchange = _Exchange(monkeypatch, _granted(), _granted())
141
- monkeypatch.setattr(requests, "get", lambda url, **kwargs: _Answer(200, {}))
141
+ monkeypatch.setattr(requests.Session, "get", lambda _session, url, **kwargs: _Answer(200, {}))
142
142
 
143
143
  # Importing the client configures the logger, whose start-up exchange runs first.
144
144
  utils = load_sdk_submodule("mainsequence.client.utils")
@@ -306,7 +306,7 @@ def test_exchange_retries_are_bounded(monkeypatch, runtime):
306
306
  def test_each_attempt_receives_only_the_remaining_budget(monkeypatch, runtime):
307
307
  _use_token_file(monkeypatch, runtime)
308
308
  utils = load_sdk_submodule("mainsequence.client.utils")
309
- transport = importlib.import_module("mainsequence.client._transport")
309
+ transport = importlib.import_module("mainsequence._transport")
310
310
  exchange = _Exchange(monkeypatch, _Answer(503, headers={"Retry-After": "4"}), _granted())
311
311
  now = [100.0]
312
312
 
@@ -434,7 +434,7 @@ def test_logger_start_up_exchange_sends_the_identity_token_and_retries(monkeypat
434
434
  authorizations.append(kwargs["headers"].get("Authorization"))
435
435
  return _Answer(200, {"job_run_uid": JOB_RUN_UID})
436
436
 
437
- monkeypatch.setattr(requests, "get", _get)
437
+ monkeypatch.setattr(requests.Session, "get", lambda _session, url, **kw: _get(url, **kw))
438
438
 
439
439
  load_sdk_submodule("mainsequence.logconf")
440
440
 
@@ -452,7 +452,7 @@ def test_logger_start_up_exchange_needs_the_identity_token_file(monkeypatch, run
452
452
  monkeypatch.setenv(SECRET_ENV, SECRET)
453
453
  monkeypatch.setenv("JOB_RUN_UID", JOB_RUN_UID)
454
454
  exchange = _Exchange(monkeypatch)
455
- monkeypatch.setattr(requests, "get", lambda url, **kwargs: _Answer(401))
455
+ monkeypatch.setattr(requests.Session, "get", lambda _session, url, **kwargs: _Answer(401))
456
456
 
457
457
  load_sdk_submodule("mainsequence.logconf")
458
458