Skip to content

Commit 3f3421b

Browse files
committed
feat(client): retry transient GET failures with backoff
Idempotent GETs now retry on 500/502/503/504 and transient transport errors (connection reset/refused, timeout, truncated body) with doubling backoff capped at 30s, up to 7 attempts per logical request. Mutating methods are never duplicated and streamed GETs are excluded so stream consumers keep control of the open path. SSL certificate errors raise on the first attempt because they are deterministic and retrying only delays the report. The existing 429 Retry-After handling is unchanged and composes with the new layer. Retry warnings go through the client logger; pipeline-run, artifact, pipeline hydration, published-component, and secret commands thread their --log-type logger into the client so the warnings follow the configured sink, and logger-less programmatic clients stay silent.
1 parent f1d2dee commit 3f3421b

11 files changed

Lines changed: 622 additions & 14 deletions

packages/tangle-cli/src/tangle_cli/artifacts_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def artifacts_get(
8080
header=args.header,
8181
include_env_credentials=include_env_credentials_for_args(args, base_url),
8282
command_name="artifact commands",
83+
logger=logger,
8384
)
8485
if require_available := getattr(client, "require_available", None):
8586
require_available()

packages/tangle-cli/src/tangle_cli/client.py

Lines changed: 145 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@
4040
)
4141

4242

43+
class _RetryBudget:
44+
"""Shared attempt/deadline budget for one logical request.
45+
46+
The transient-5xx, 429 rate-limit, and 401 auth-refresh retry layers all
47+
draw from a single instance so a composed outage cannot multiply their
48+
per-layer limits into a large physical request count. ``remaining`` counts
49+
the physical requests still permitted; ``deadline`` is a ``time.monotonic``
50+
value past which no further retry is attempted.
51+
"""
52+
53+
__slots__ = ("remaining", "deadline")
54+
55+
def __init__(self, max_attempts: int, deadline: float) -> None:
56+
self.remaining = max_attempts
57+
self.deadline = deadline
58+
59+
def consume(self) -> None:
60+
self.remaining -= 1
61+
62+
def can_retry(self) -> bool:
63+
return self.remaining > 0 and time.monotonic() < self.deadline
64+
65+
4366
class TangleApiClient(GeneratedTangleApiOperations):
4467
"""Single public API wrapper for Tangle backends.
4568
@@ -50,9 +73,19 @@ class TangleApiClient(GeneratedTangleApiOperations):
5073

5174
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
5275
_MAX_REDIRECTS = 5
53-
_MAX_RATE_LIMIT_RETRIES = 3
5476
_RATE_LIMIT_BACKOFF_SECONDS = 1.0
5577
_MAX_RETRY_AFTER_SECONDS = 60.0
78+
_RETRYABLE_GET_STATUSES = frozenset({500, 502, 503, 504})
79+
_MAX_GET_RETRIES = 6
80+
_GET_RETRY_BACKOFF_SECONDS = 1.0
81+
_MAX_GET_RETRY_BACKOFF_SECONDS = 30.0
82+
# A single logical request may issue at most ``_MAX_GET_RETRIES + 1``
83+
# physical requests total, shared across the transient-5xx, 429 rate-limit,
84+
# and 401 auth-refresh layers, and must not spend more than
85+
# ``_MAX_RETRY_ELAPSED_SECONDS`` retrying. One shared budget prevents the
86+
# layers from multiplying into a large physical request count during an
87+
# outage (e.g. interleaved 503/429 responses, or a 401 mid-sequence).
88+
_MAX_RETRY_ELAPSED_SECONDS = 120.0
5689

5790
def __init__(
5891
self,
@@ -121,6 +154,11 @@ def _make_request(
121154
clean_params = self._clean_mapping(params)
122155
request_method = method.upper()
123156

157+
budget = _RetryBudget(
158+
self._MAX_GET_RETRIES + 1,
159+
time.monotonic() + self._MAX_RETRY_ELAPSED_SECONDS,
160+
)
161+
124162
self._refresh_auth()
125163
response = self._request_with_rate_limit_retries(
126164
request_method,
@@ -130,8 +168,11 @@ def _make_request(
130168
extra_headers=extra_headers,
131169
timeout=timeout,
132170
request_kwargs=kwargs,
171+
budget=budget,
133172
)
134-
if response.status_code == 401:
173+
# The auth-refresh retry draws from the same budget, so a 401 late in a
174+
# transient/rate-limit sequence cannot start a fresh round of retries.
175+
if response.status_code == 401 and budget.can_retry():
135176
self._refresh_auth()
136177
response = self._request_with_rate_limit_retries(
137178
request_method,
@@ -141,6 +182,7 @@ def _make_request(
141182
extra_headers=extra_headers,
142183
timeout=timeout,
143184
request_kwargs=kwargs,
185+
budget=budget,
144186
)
145187
return response
146188

@@ -154,22 +196,117 @@ def _request_with_rate_limit_retries(
154196
extra_headers: Mapping[str, str] | None,
155197
timeout: float,
156198
request_kwargs: Mapping[str, Any],
199+
budget: _RetryBudget,
157200
) -> requests.Response:
158-
response: requests.Response | None = None
159-
for attempt in range(self._MAX_RATE_LIMIT_RETRIES + 1):
160-
response = self._request_with_same_origin_redirects(
201+
rate_limit_round = 0
202+
while True:
203+
response = self._request_with_transient_retries(
161204
method,
162205
url,
163206
params=params,
164207
json_data=json_data,
165208
extra_headers=extra_headers,
166209
timeout=timeout,
167210
request_kwargs=request_kwargs,
211+
budget=budget,
168212
)
169-
if response.status_code != 429 or attempt == self._MAX_RATE_LIMIT_RETRIES:
213+
# A 429 retry re-enters the transient layer, so it must draw from the
214+
# shared budget rather than a per-round allowance.
215+
if response.status_code != 429 or not budget.can_retry():
170216
return response
171-
self._sleep_for_rate_limit(response, attempt)
172-
return response
217+
self._sleep_for_rate_limit(response, rate_limit_round)
218+
rate_limit_round += 1
219+
220+
def _request_with_transient_retries(
221+
self,
222+
method: str,
223+
url: str,
224+
*,
225+
params: Mapping[str, Any] | None,
226+
json_data: Any,
227+
extra_headers: Mapping[str, str] | None,
228+
timeout: float,
229+
request_kwargs: Mapping[str, Any],
230+
budget: _RetryBudget,
231+
) -> requests.Response:
232+
"""Retry idempotent GETs on transient 5xx and transport errors.
233+
234+
Mutating methods are sent once (never duplicated). Streamed GETs bypass
235+
this layer so their consumer owns any stream-open retries. 429s are left
236+
to the rate-limit layer, whose retries re-enter this layer with a fresh
237+
backoff. ``SSLError`` raises immediately: certificate failures are
238+
deterministic, so retrying only delays the report. Every physical send
239+
draws from the shared ``budget`` so the transient, rate-limit, and
240+
auth-refresh layers cannot multiply into a large request count. Each
241+
doubling sleep is capped at ``_MAX_GET_RETRY_BACKOFF_SECONDS`` and
242+
announced through ``self.logger`` (a null logger on non-verbose clients
243+
built without one), so a stalled GET is bounded.
244+
"""
245+
246+
if method.upper() != "GET" or request_kwargs.get("stream"):
247+
budget.consume()
248+
return self._request_with_same_origin_redirects(
249+
method,
250+
url,
251+
params=params,
252+
json_data=json_data,
253+
extra_headers=extra_headers,
254+
timeout=timeout,
255+
request_kwargs=request_kwargs,
256+
)
257+
258+
backoff = self._GET_RETRY_BACKOFF_SECONDS
259+
attempt = 0
260+
while True:
261+
budget.consume()
262+
attempt += 1
263+
try:
264+
response = self._request_with_same_origin_redirects(
265+
method,
266+
url,
267+
params=params,
268+
json_data=json_data,
269+
extra_headers=extra_headers,
270+
timeout=timeout,
271+
request_kwargs=request_kwargs,
272+
)
273+
# Transient transport failures (reset/refused, timeout, truncated or
274+
# corrupt body) can succeed on retry; other request errors surface.
275+
except (
276+
requests.ConnectionError,
277+
requests.Timeout,
278+
requests.exceptions.ChunkedEncodingError,
279+
requests.exceptions.ContentDecodingError,
280+
) as exc:
281+
# SSLError subclasses ConnectionError but signals a certificate
282+
# or TLS configuration problem that no retry can fix.
283+
if isinstance(exc, requests.exceptions.SSLError):
284+
raise
285+
# Budget exhausted (attempts or deadline): surface the failure.
286+
if not budget.can_retry():
287+
raise
288+
self._sleep_for_transient_retry(backoff, attempt, type(exc).__name__)
289+
else:
290+
if response.status_code not in self._RETRYABLE_GET_STATUSES:
291+
return response
292+
# Budget exhausted: return the final 5xx for raise_for_status.
293+
if not budget.can_retry():
294+
return response
295+
# Release the intermediate response so its connection returns to the pool.
296+
try:
297+
response.close()
298+
except Exception:
299+
pass
300+
self._sleep_for_transient_retry(backoff, attempt, f"HTTP {response.status_code}")
301+
backoff *= 2.0
302+
303+
def _sleep_for_transient_retry(self, backoff: float, attempt: int, reason: str) -> None:
304+
delay = min(backoff, self._MAX_GET_RETRY_BACKOFF_SECONDS)
305+
self.logger.warn(
306+
f"transient {reason} on GET; retrying in {delay:.1f}s "
307+
f"(attempt {attempt + 1}/{self._MAX_GET_RETRIES + 1})"
308+
)
309+
time.sleep(delay)
173310

174311
def _sleep_for_rate_limit(self, response: requests.Response, attempt: int) -> None:
175312
retry_after = response.headers.get("Retry-After")

packages/tangle-cli/src/tangle_cli/pipeline_runs_cli.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,25 @@ def _allow_all_hydration_for_args(args: ArgsContainer) -> bool:
6969
return bool(config.get("allow_all", False))
7070

7171

72-
def _api_client(args: ArgsContainer, *, cli_base_url: str | None, command_name: str) -> LazyTangleApiClient:
72+
def _api_client(
73+
args: ArgsContainer, *, cli_base_url: str | None, command_name: str, logger: Logger | None = None
74+
) -> LazyTangleApiClient:
7375
return LazyTangleApiClient(
7476
base_url=args.base_url,
7577
token=args.token,
7678
auth_header=args.auth_header,
7779
header=args.header,
7880
include_env_credentials=include_env_credentials_for_args(args, cli_base_url),
7981
command_name=command_name,
82+
logger=logger,
8083
)
8184

8285

8386
def _manager(args: ArgsContainer, *, cli_base_url: str | None, logger: Logger) -> PipelineRunManager:
8487
return PipelineRunManager(
85-
client=_api_client(args, cli_base_url=cli_base_url, command_name="pipeline-run commands"),
88+
client=_api_client(
89+
args, cli_base_url=cli_base_url, command_name="pipeline-run commands", logger=logger
90+
),
8691
hooks=PipelineRunHooks(
8792
logger=logger,
8893
trusted_python_sources=_trusted_sources_for_args(args),
@@ -117,7 +122,12 @@ def _run_annotation_action(config: str | None, cli_base_url: str | None, specs:
117122
raise SystemExit(str(exc)) from exc
118123
try:
119124
manager = AnnotationManager(
120-
client=_api_client(args, cli_base_url=cli_base_url, command_name="pipeline-run annotation commands"),
125+
client=_api_client(
126+
args,
127+
cli_base_url=cli_base_url,
128+
command_name="pipeline-run annotation commands",
129+
logger=logger,
130+
),
121131
logger=logger,
122132
)
123133
print_json(fn(manager, args))

packages/tangle-cli/src/tangle_cli/pipelines_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def pipelines_hydrate(
232232
),
233233
header=_header_entries(header, config_values),
234234
include_env_credentials=include_env_credentials,
235+
logger=logger,
235236
),
236237
)
237238
except PipelineValidationError as exc:

packages/tangle-cli/src/tangle_cli/published_components_cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
TokenOption,
2525
)
2626
from .component_publisher import ComponentPublisher, deprecate_component
27-
from .logger import logger_for_log_type
27+
from .logger import Logger, logger_for_log_type
2828

2929

3030
def _client_from_options(
@@ -35,6 +35,7 @@ def _client_from_options(
3535
header: list[str] | str | None = None,
3636
include_env_credentials: bool = True,
3737
command_name: str = "published-component commands",
38+
logger: Logger | None = None,
3839
) -> LazyTangleApiClient:
3940
"""Create a lazy static client proxy for published-component commands.
4041
@@ -49,6 +50,7 @@ def _client_from_options(
4950
header=header,
5051
include_env_credentials=include_env_credentials,
5152
command_name=command_name,
53+
logger=logger,
5254
)
5355

5456

@@ -97,6 +99,7 @@ def published_components_search(
9799
header=args.header,
98100
include_env_credentials=include_env_credentials_for_args(args, base_url),
99101
command_name="published-component commands",
102+
logger=logger,
100103
)
101104
if require_available := getattr(client, "require_available", None):
102105
require_available()
@@ -163,6 +166,7 @@ def published_components_inspect(
163166
header=args.header,
164167
include_env_credentials=include_env_credentials_for_args(args, base_url),
165168
command_name="published-component commands",
169+
logger=logger,
166170
)
167171
if require_available := getattr(client, "require_available", None):
168172
require_available()
@@ -219,6 +223,7 @@ def published_components_library(
219223
header=args.header,
220224
include_env_credentials=include_env_credentials_for_args(args, base_url),
221225
command_name="published-component commands",
226+
logger=logger,
222227
)
223228
if require_available := getattr(client, "require_available", None):
224229
require_available()
@@ -288,6 +293,7 @@ def published_components_publish(
288293
header=args.header,
289294
include_env_credentials=include_env_credentials_for_args(args, base_url),
290295
command_name="published-component commands",
296+
logger=logger,
291297
)
292298
publisher = ComponentPublisher(
293299
dry_run=bool(args.dry_run),
@@ -358,6 +364,7 @@ def published_components_deprecate(
358364
header=args.header,
359365
include_env_credentials=include_env_credentials_for_args(args, base_url),
360366
command_name="published-component commands",
367+
logger=logger,
361368
)
362369
result = deprecate_component(
363370
client,

packages/tangle-cli/src/tangle_cli/secrets_cli.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,17 @@
5858
app = App(name="secrets", help="Manage Tangle secrets.")
5959

6060

61-
def _client(args: ArgsContainer, *, cli_base_url: str | None, command_name: str) -> LazyTangleApiClient:
61+
def _client(
62+
args: ArgsContainer, *, cli_base_url: str | None, command_name: str, logger: Logger | None = None
63+
) -> LazyTangleApiClient:
6264
return LazyTangleApiClient(
6365
base_url=args.base_url,
6466
token=args.token,
6567
auth_header=args.auth_header,
6668
header=args.header,
6769
include_env_credentials=include_env_credentials_for_args(args, cli_base_url),
6870
command_name=command_name,
71+
logger=logger,
6972
)
7073

7174

@@ -79,7 +82,7 @@ def _run_secret_action(
7982
for args in load_args_or_exit(config, **specs):
8083
logger, finalize_logs = logger_for_log_type(getattr(args, "log_type", "console"))
8184
try:
82-
client = _client(args, cli_base_url=cli_base_url, command_name="secret commands")
85+
client = _client(args, cli_base_url=cli_base_url, command_name="secret commands", logger=logger)
8386
try:
8487
results.append(fn(client, args, logger))
8588
except SecretValueError as exc:

tests/test_api_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib
22
import json
33
import sys
4+
from unittest.mock import ANY
45

56
import httpx
67
import pytest
@@ -460,6 +461,7 @@ def fake_client_from_options(**kwargs):
460461
"header": ["X-Config: yes"],
461462
"include_env_credentials": False,
462463
"command_name": "published-component commands",
464+
"logger": ANY,
463465
}
464466

465467

tests/test_artifacts_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import sys
77
from typing import Any
8+
from unittest.mock import ANY
89

910
from tangle_cli import artifacts as artifacts_module
1011
from tangle_cli import artifacts_cli, cli
@@ -80,6 +81,7 @@ def fake_get_artifacts(self, run_id: str, query: dict[str, Any]) -> dict[str, ob
8081
"header": ["X-Config: yes"],
8182
"include_env_credentials": False,
8283
"command_name": "artifact commands",
84+
"logger": ANY,
8385
}
8486
]
8587
assert get_calls == [

0 commit comments

Comments
 (0)