steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/cloud/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Clients for a Steppy control plane.
|
|
2
|
+
|
|
3
|
+
Steppy works with no control plane at all, and nothing outside this package may
|
|
4
|
+
assume one exists. What lives here is the *client* half of the versioned wire
|
|
5
|
+
contracts a plane publishes — pushing results (``steppy bundle upload``), and
|
|
6
|
+
later claiming work (``steppy ci run``, ``steppy agent``). They are open source
|
|
7
|
+
on purpose: they run inside a customer's CI holding their repository and their
|
|
8
|
+
credentials, and a closed binary doing that fails the security review Steppy's
|
|
9
|
+
whole positioning is built on passing.
|
|
10
|
+
|
|
11
|
+
**Lazy-imported only.** ``httpx`` costs ~40ms at import and no other command
|
|
12
|
+
needs it, so every import of this package lives inside a command body. Guard:
|
|
13
|
+
``tests/test_no_cloud_import_on_default_path.py``.
|
|
14
|
+
|
|
15
|
+
Split by concern, and the split is the boundary worth keeping:
|
|
16
|
+
|
|
17
|
+
``client`` transport — endpoint resolution, bearer auth, retries, errors
|
|
18
|
+
``upload`` the bundle upload protocol, and the only module that knows what a
|
|
19
|
+
bundle is
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .client import CloudAuthError, CloudClient, CloudError, Endpoint, resolve_endpoint
|
|
25
|
+
from .upload import ArchiveSource, BlobSource, TreeSource, UploadOutcome, derive_exit_code, upload_bundle
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cloud_client(
|
|
29
|
+
*,
|
|
30
|
+
url: Optional[str] = None,
|
|
31
|
+
token: Optional[str] = None,
|
|
32
|
+
config_url: Optional[str] = None,
|
|
33
|
+
timeout: float = 60.0,
|
|
34
|
+
) -> CloudClient:
|
|
35
|
+
"""Resolve an endpoint and open a session against it.
|
|
36
|
+
|
|
37
|
+
The two halves are separately useful — tests build a client around a fake
|
|
38
|
+
transport, and a future ``steppy cloud status`` will want the endpoint
|
|
39
|
+
without a session — so this is only the pairing every command actually
|
|
40
|
+
performs, not a replacement for either.
|
|
41
|
+
"""
|
|
42
|
+
return CloudClient(resolve_endpoint(url, token, config_url=config_url), timeout=timeout)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ArchiveSource",
|
|
47
|
+
"BlobSource",
|
|
48
|
+
"CloudAuthError",
|
|
49
|
+
"CloudClient",
|
|
50
|
+
"CloudError",
|
|
51
|
+
"Endpoint",
|
|
52
|
+
"TreeSource",
|
|
53
|
+
"UploadOutcome",
|
|
54
|
+
"cloud_client",
|
|
55
|
+
"derive_exit_code",
|
|
56
|
+
"resolve_endpoint",
|
|
57
|
+
"upload_bundle",
|
|
58
|
+
]
|
steppy/cloud/client.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""HTTP transport for a Steppy control plane.
|
|
2
|
+
|
|
3
|
+
Knows about bearer tokens, retries and error shapes. Knows nothing about
|
|
4
|
+
bundles — that is :mod:`steppy.cloud.upload`'s job, and keeping the two apart is
|
|
5
|
+
what will let ``steppy ci run`` and ``steppy agent`` reuse this unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, BinaryIO, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
#: Environment variables, checked after an explicit flag and before config.
|
|
19
|
+
ENV_URL = "STEPPY_CLOUD_URL"
|
|
20
|
+
ENV_TOKEN = "STEPPY_CLOUD_TOKEN"
|
|
21
|
+
|
|
22
|
+
#: The API version this client speaks. Appended to the configured base URL, so a
|
|
23
|
+
#: project's `cloud.url` never has to be edited when the server adds v2.
|
|
24
|
+
API_VERSION = "v1"
|
|
25
|
+
|
|
26
|
+
DEFAULT_TIMEOUT = 60.0
|
|
27
|
+
DEFAULT_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
#: Retried with backoff. 429 is included because it is the server asking us to
|
|
30
|
+
#: slow down, not to stop; 5xx because a control plane behind a load balancer
|
|
31
|
+
#: will occasionally return one that a second attempt does not.
|
|
32
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CloudError(Exception):
|
|
36
|
+
"""A request the plane refused, or a transport that never got there."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, *, status: Optional[int] = None) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CloudAuthError(CloudError):
|
|
44
|
+
"""401 or 403.
|
|
45
|
+
|
|
46
|
+
Split out because it is the one failure that must **never** be retried: a
|
|
47
|
+
revoked token retried with backoff is a client that hammers a server it can
|
|
48
|
+
never satisfy, and the same rule governs the agent long-poll ("401 means
|
|
49
|
+
stop, not back off"). It is also the error most likely to be a user's typo,
|
|
50
|
+
so its message names the two places a token can come from.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Endpoint:
|
|
56
|
+
"""A resolved control plane: where it is, and what proves who we are."""
|
|
57
|
+
|
|
58
|
+
url: str
|
|
59
|
+
token: str
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def api(self) -> str:
|
|
63
|
+
return f"{self.url}/{API_VERSION}"
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
66
|
+
# Never let a token reach a traceback, a log, or a --verbose dump.
|
|
67
|
+
return f"Endpoint(url={self.url!r}, token='***')"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _clean_url(raw: str) -> str:
|
|
71
|
+
url = raw.strip().rstrip("/")
|
|
72
|
+
# Forgive the URL people actually have in front of them. `/v1` is what the
|
|
73
|
+
# docs and every error message show, so it is what gets pasted; silently
|
|
74
|
+
# accepting it beats a 404 on `…/v1/v1/runs` that reads like the server is
|
|
75
|
+
# broken.
|
|
76
|
+
if url.endswith(f"/{API_VERSION}"):
|
|
77
|
+
url = url[: -(len(API_VERSION) + 1)]
|
|
78
|
+
return url
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_endpoint(
|
|
82
|
+
url: Optional[str] = None,
|
|
83
|
+
token: Optional[str] = None,
|
|
84
|
+
*,
|
|
85
|
+
config_url: Optional[str] = None,
|
|
86
|
+
) -> Endpoint:
|
|
87
|
+
"""Resolve the plane's address and credential: flag → env → config.
|
|
88
|
+
|
|
89
|
+
**The token has no config source, deliberately.** ``config.yaml`` is
|
|
90
|
+
committed, and a control-plane token is a credential — the same line
|
|
91
|
+
:func:`steppy.config._check_credential_hygiene` already draws for
|
|
92
|
+
``apps.*.auth.credentials``. Leaving the key out entirely is stronger than
|
|
93
|
+
warning about it, because there is then nowhere to paste it by accident.
|
|
94
|
+
"""
|
|
95
|
+
resolved_url = url or os.environ.get(ENV_URL) or config_url
|
|
96
|
+
if not resolved_url:
|
|
97
|
+
raise CloudError(
|
|
98
|
+
f"no control plane URL. Pass --url, set {ENV_URL}, or add `cloud: {{ url: https://… }}` to config.yaml"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
resolved_token = token or os.environ.get(ENV_TOKEN)
|
|
102
|
+
if not resolved_token:
|
|
103
|
+
raise CloudError(
|
|
104
|
+
f"no control plane token. Set {ENV_TOKEN} or pass --token. "
|
|
105
|
+
"There is no config.yaml key for it on purpose — that file is committed"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return Endpoint(url=_clean_url(resolved_url), token=resolved_token.strip())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _detail(response: httpx.Response) -> str:
|
|
112
|
+
"""The server's own explanation, which is written to be read by a user."""
|
|
113
|
+
try:
|
|
114
|
+
payload = response.json()
|
|
115
|
+
except ValueError:
|
|
116
|
+
return response.text.strip()[:500] or f"HTTP {response.status_code}"
|
|
117
|
+
if isinstance(payload, dict):
|
|
118
|
+
detail = payload.get("detail")
|
|
119
|
+
if isinstance(detail, str):
|
|
120
|
+
return detail
|
|
121
|
+
if detail is not None:
|
|
122
|
+
return str(detail)
|
|
123
|
+
return str(payload)[:500]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CloudClient:
|
|
127
|
+
"""A session against one plane.
|
|
128
|
+
|
|
129
|
+
Two httpx clients, and the second one is not an oversight. Presigned URLs
|
|
130
|
+
carry their authorisation in the query string, and an object store that
|
|
131
|
+
receives a presigned signature **and** an ``Authorization`` header rejects
|
|
132
|
+
the pair as ambiguous — a 400 that reads like a bad URL. So blob uploads go
|
|
133
|
+
out on a client that has no default headers at all.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
endpoint: Endpoint,
|
|
139
|
+
*,
|
|
140
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
141
|
+
retries: int = DEFAULT_RETRIES,
|
|
142
|
+
user_agent: Optional[str] = None,
|
|
143
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
from .. import __version__
|
|
146
|
+
|
|
147
|
+
self.endpoint = endpoint
|
|
148
|
+
self._retries = max(0, retries)
|
|
149
|
+
# `transport` exists for tests, and it is worth the parameter: an
|
|
150
|
+
# `httpx.MockTransport` exercises the real client — its header merging,
|
|
151
|
+
# its Content-Length, its retries — where a stubbed CloudClient would
|
|
152
|
+
# verify only that the test's own stub was called.
|
|
153
|
+
self._api = httpx.Client(
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
transport=transport,
|
|
156
|
+
headers={
|
|
157
|
+
"Authorization": f"Bearer {endpoint.token}",
|
|
158
|
+
"User-Agent": user_agent or f"steppy/{__version__}",
|
|
159
|
+
"Accept": "application/json",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
# Longer timeout: a blob PUT is bytes over the wire, not a query.
|
|
163
|
+
self._blobs = httpx.Client(timeout=max(timeout, 300.0), transport=transport, headers={})
|
|
164
|
+
|
|
165
|
+
def __enter__(self) -> CloudClient:
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __exit__(self, *exc: object) -> None:
|
|
169
|
+
self.close()
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
self._api.close()
|
|
173
|
+
self._blobs.close()
|
|
174
|
+
|
|
175
|
+
# --- API calls ----------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def get(self, path: str, *, params: Optional[dict[str, Any]] = None, token: Optional[str] = None) -> Any:
|
|
178
|
+
return self._json("GET", path, params=params, token=token)
|
|
179
|
+
|
|
180
|
+
def post(
|
|
181
|
+
self,
|
|
182
|
+
path: str,
|
|
183
|
+
*,
|
|
184
|
+
json: Any = None,
|
|
185
|
+
headers: Optional[dict[str, str]] = None,
|
|
186
|
+
token: Optional[str] = None,
|
|
187
|
+
) -> Any:
|
|
188
|
+
return self._json("POST", path, json=json, headers=headers, token=token)
|
|
189
|
+
|
|
190
|
+
def _json(
|
|
191
|
+
self,
|
|
192
|
+
method: str,
|
|
193
|
+
path: str,
|
|
194
|
+
*,
|
|
195
|
+
json: Any = None,
|
|
196
|
+
params: Optional[dict[str, Any]] = None,
|
|
197
|
+
headers: Optional[dict[str, str]] = None,
|
|
198
|
+
token: Optional[str] = None,
|
|
199
|
+
) -> Any:
|
|
200
|
+
url = f"{self.endpoint.api}{path}"
|
|
201
|
+
request_headers = dict(headers or {})
|
|
202
|
+
if token is not None:
|
|
203
|
+
# A claim token authorises exactly one task. Swapping it in per
|
|
204
|
+
# request rather than per client keeps one connection pool for the
|
|
205
|
+
# whole upload — and makes the narrower credential the explicit
|
|
206
|
+
# choice at each call site.
|
|
207
|
+
request_headers["Authorization"] = f"Bearer {token}"
|
|
208
|
+
|
|
209
|
+
response = self._send(lambda: self._api.request(method, url, json=json, params=params, headers=request_headers))
|
|
210
|
+
if not response.content:
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
return response.json()
|
|
214
|
+
except ValueError as e:
|
|
215
|
+
raise CloudError(f"{method} {path}: the server did not return JSON ({e})") from e
|
|
216
|
+
|
|
217
|
+
def put_blob(self, url: str, opener, size: int) -> None:
|
|
218
|
+
"""Upload one blob to a presigned URL.
|
|
219
|
+
|
|
220
|
+
``Content-Length`` is set explicitly because it is signed into the URL:
|
|
221
|
+
without it httpx would stream chunked and the store would reject the
|
|
222
|
+
request. It is also what makes a truncated read a rejected upload rather
|
|
223
|
+
than a silently corrupt blob.
|
|
224
|
+
"""
|
|
225
|
+
with opener() as handle:
|
|
226
|
+
self._send(
|
|
227
|
+
lambda: self._blobs.put(url, content=handle, headers={"Content-Length": str(size)}),
|
|
228
|
+
rewind=handle,
|
|
229
|
+
# A 403 here is an *expired presigned URL*, not a bad credential —
|
|
230
|
+
# there is no credential on this request. Raising CloudAuthError
|
|
231
|
+
# would send a fixable condition down the "stop, do not retry"
|
|
232
|
+
# path; the caller re-offers instead. See `upload._EXPIRED`.
|
|
233
|
+
auth_fatal=False,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# --- transport ----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _send(self, call, *, rewind: Optional[BinaryIO] = None, auth_fatal: bool = True) -> httpx.Response:
|
|
239
|
+
"""Issue a request, retrying only what retrying can fix."""
|
|
240
|
+
last: Optional[Exception] = None
|
|
241
|
+
for attempt in range(self._retries + 1):
|
|
242
|
+
if attempt and rewind is not None:
|
|
243
|
+
# A retried upload must resend from byte zero. Without this the
|
|
244
|
+
# second attempt sends whatever the first one had not yet read,
|
|
245
|
+
# and the store stores a valid-looking truncated blob.
|
|
246
|
+
rewind.seek(0)
|
|
247
|
+
try:
|
|
248
|
+
response = call()
|
|
249
|
+
except httpx.HTTPError as e:
|
|
250
|
+
last = CloudError(f"could not reach the control plane: {e}")
|
|
251
|
+
else:
|
|
252
|
+
if auth_fatal and response.status_code in (401, 403):
|
|
253
|
+
raise CloudAuthError(_detail(response), status=response.status_code)
|
|
254
|
+
if response.status_code < 400:
|
|
255
|
+
return response
|
|
256
|
+
error = CloudError(_detail(response), status=response.status_code)
|
|
257
|
+
if response.status_code not in _RETRY_STATUSES:
|
|
258
|
+
raise error
|
|
259
|
+
last = error
|
|
260
|
+
|
|
261
|
+
if attempt < self._retries:
|
|
262
|
+
# Full jitter: N clients that failed together must not retry
|
|
263
|
+
# together.
|
|
264
|
+
time.sleep(random.uniform(0, min(8.0, 0.5 * 2**attempt)))
|
|
265
|
+
|
|
266
|
+
assert last is not None
|
|
267
|
+
raise last
|