Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit a8d0e9a

Browse files
authored
Merge branch 'main' into close_ended_lease
2 parents 63368c3 + 312a389 commit a8d0e9a

3 files changed

Lines changed: 143 additions & 8 deletions

File tree

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from grpc.aio import AioRpcError, Channel
2424
from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc
2525
from rich.console import Console
26+
from tenacity import retry, retry_if_exception_type, wait_exponential_jitter
2627

2728
from .exceptions import LeaseError
2829
from jumpstarter.client import client_from_path
@@ -80,6 +81,24 @@ async def get(self):
8081
svc = ClientService(channel=self.channel, namespace=self.namespace)
8182
return await svc.GetLease(name=self.name)
8283

84+
@retry(
85+
wait=wait_exponential_jitter(initial=1, max=120, jitter=1),
86+
retry=retry_if_exception_type(ConnectionError),
87+
reraise=True,
88+
)
89+
async def _get_with_retry(self):
90+
"""Get lease with exponential backoff retry on ConnectionError.
91+
92+
Retries with exponential backoff and jitter indefinitely when ConnectionError occurs.
93+
The wait time between retries is capped at 2 minutes (120 seconds).
94+
Jitter helps prevent thundering herd problems when multiple clients retry simultaneously.
95+
"""
96+
try:
97+
return await self.get()
98+
except ConnectionError as e:
99+
logger.error("Error while getting lease %s: %s", self.name, e)
100+
raise
101+
83102
def request(self):
84103
"""Request a lease, or verifies a lease which was already created.
85104
@@ -139,12 +158,11 @@ async def _acquire(self):
139158
with LeaseAcquisitionSpinner(self.name) as spinner:
140159
while True:
141160
logger.debug("Polling Lease %s", self.name)
142-
result = await self.get()
143-
161+
result = await self._get_with_retry()
144162
# lease ready
145163
if condition_true(result.conditions, "Ready"):
146164
logger.debug("Lease %s acquired", self.name)
147-
spinner.update_status(f"Lease {self.name} acquired successfully!")
165+
spinner.update_status(f"Lease {self.name} acquired successfully!", force=True)
148166
self.exporter_name = result.exporter
149167
break
150168

@@ -322,6 +340,8 @@ def __init__(self, lease_name: str | None = None):
322340
self.start_time = None
323341
self._should_show_spinner = self._is_terminal_available() and not self._is_non_interactive()
324342
self._current_message = None
343+
self._last_log_time = None
344+
self._log_throttle_interval = timedelta(minutes=5)
325345

326346
def _is_non_interactive(self) -> bool:
327347
"""Check if the user desires a NONINTERACTIVE environment."""
@@ -349,18 +369,32 @@ def __exit__(self, exc_type, exc_val, exc_tb):
349369
if self.spinner:
350370
self.spinner.stop()
351371

352-
def update_status(self, message: str):
353-
"""Update the spinner status message."""
372+
def update_status(self, message: str, force: bool = False):
373+
"""Update the spinner status message.
374+
375+
:param message: The status message to display
376+
:param force: If True, always log the message even when throttling (default: False)
377+
"""
354378
if self.spinner and self._should_show_spinner:
355379
self._current_message = f"[blue]{message}[/blue]"
356380
elapsed = datetime.now() - self.start_time
357381
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
358382
self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]")
359383
else:
360384
# Log info message when no console is available
361-
elapsed = datetime.now() - self.start_time
362-
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
363-
logger.info(f"{message} ({elapsed_str})")
385+
# Throttle updates to at most every 5 minutes unless forced
386+
now = datetime.now()
387+
should_log = (
388+
force
389+
or self._last_log_time is None
390+
or (now - self._last_log_time) >= self._log_throttle_interval
391+
)
392+
393+
if should_log:
394+
elapsed = now - self.start_time
395+
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
396+
logger.info(f"{message} ({elapsed_str})")
397+
self._last_log_time = now
364398

365399
def tick(self):
366400
"""Update the spinner with current elapsed time without changing the message."""

packages/jumpstarter/jumpstarter/client/lease_test.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,103 @@ def test_start_time_initialization_in_context(self):
228228
with spinner:
229229
assert spinner.start_time is not None
230230
assert isinstance(spinner.start_time, datetime)
231+
232+
def test_throttling_first_update_logged(self, caplog):
233+
"""Test that the first update is always logged when console is not available."""
234+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
235+
spinner = LeaseAcquisitionSpinner("test-lease")
236+
spinner.start_time = datetime.now()
237+
238+
with caplog.at_level(logging.INFO):
239+
spinner.update_status("First message")
240+
241+
assert "First message" in caplog.text
242+
assert spinner._last_log_time is not None
243+
244+
def test_throttling_second_update_within_interval_not_logged(self, caplog):
245+
"""Test that updates within 5 minutes are not logged."""
246+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
247+
spinner = LeaseAcquisitionSpinner("test-lease")
248+
spinner.start_time = datetime.now()
249+
spinner._last_log_time = datetime.now() - timedelta(minutes=2) # 2 minutes ago
250+
251+
with caplog.at_level(logging.INFO):
252+
spinner.update_status("Second message")
253+
254+
# Should not log because only 2 minutes have passed
255+
assert "Second message" not in caplog.text
256+
257+
def test_throttling_update_after_interval_logged(self, caplog):
258+
"""Test that updates after 5 minutes are logged."""
259+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
260+
spinner = LeaseAcquisitionSpinner("test-lease")
261+
spinner.start_time = datetime.now()
262+
spinner._last_log_time = datetime.now() - timedelta(minutes=6) # 6 minutes ago
263+
264+
with caplog.at_level(logging.INFO):
265+
spinner.update_status("After interval message")
266+
267+
assert "After interval message" in caplog.text
268+
assert spinner._last_log_time is not None
269+
270+
def test_throttling_forced_update_always_logged(self, caplog):
271+
"""Test that forced updates are always logged regardless of throttle interval."""
272+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
273+
spinner = LeaseAcquisitionSpinner("test-lease")
274+
spinner.start_time = datetime.now()
275+
spinner._last_log_time = datetime.now() - timedelta(minutes=1) # 1 minute ago
276+
277+
with caplog.at_level(logging.INFO):
278+
spinner.update_status("Forced message", force=True)
279+
280+
assert "Forced message" in caplog.text
281+
assert spinner._last_log_time is not None
282+
283+
def test_throttling_multiple_updates_only_logs_when_needed(self, caplog):
284+
"""Test that multiple rapid updates only log at appropriate intervals."""
285+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
286+
spinner = LeaseAcquisitionSpinner("test-lease")
287+
spinner.start_time = datetime.now()
288+
289+
with caplog.at_level(logging.INFO):
290+
# First update should be logged
291+
spinner.update_status("Message 1")
292+
assert "Message 1" in caplog.text
293+
294+
# Set last log time to recent
295+
spinner._last_log_time = datetime.now() - timedelta(minutes=1)
296+
297+
# Second update should not be logged (within interval)
298+
spinner.update_status("Message 2")
299+
assert "Message 2" not in caplog.text
300+
301+
# Third update should not be logged (within interval)
302+
spinner.update_status("Message 3")
303+
assert "Message 3" not in caplog.text
304+
305+
# Set last log time to past the interval
306+
spinner._last_log_time = datetime.now() - timedelta(minutes=6)
307+
308+
# Fourth update should be logged (past interval)
309+
spinner.update_status("Message 4")
310+
assert "Message 4" in caplog.text
311+
312+
def test_throttling_not_applied_when_console_available(self):
313+
"""Test that throttling is not applied when console is available."""
314+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=True):
315+
spinner = LeaseAcquisitionSpinner("test-lease")
316+
spinner.start_time = datetime.now()
317+
318+
mock_spinner = Mock()
319+
spinner.spinner = mock_spinner
320+
321+
# Multiple updates should all call update() regardless of throttle
322+
spinner.update_status("Message 1")
323+
spinner.update_status("Message 2")
324+
spinner.update_status("Message 3")
325+
326+
# All should be called even if we set a recent last_log_time
327+
spinner._last_log_time = datetime.now() - timedelta(minutes=1)
328+
spinner.update_status("Message 4")
329+
330+
assert mock_spinner.update.call_count == 4

packages/jumpstarter/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"xdg-base-dirs>=6.0.2",
2020
"pydantic-settings>=2.9.1",
2121
"rich>=14.0.0",
22+
"tenacity>=8.2.0",
2223
]
2324

2425
[dependency-groups]

0 commit comments

Comments
 (0)