|
1 | 1 | from datetime import timedelta |
| 2 | +from typing import Any, Literal, Optional, Type |
2 | 3 |
|
3 | 4 | import pytest |
4 | 5 |
|
5 | 6 | from databricks.sdk.errors import NotFound, ResourceDoesNotExist |
6 | | -from databricks.sdk.retries import retried |
| 7 | +from databricks.sdk.retries import poll, retried, RetryError |
7 | 8 | from tests.clock import FakeClock |
8 | 9 |
|
9 | 10 |
|
@@ -73,3 +74,104 @@ def foo(): |
73 | 74 | raise KeyError(1) |
74 | 75 |
|
75 | 76 | foo() |
| 77 | + |
| 78 | + |
| 79 | +@pytest.mark.parametrize( |
| 80 | + "scenario,attempts,result_value,exception_type,exception_msg,timeout,min_time,max_time", |
| 81 | + [ |
| 82 | + pytest.param("success", 1, "immediate", None, None, 60, 0.0, 0.0, |
| 83 | + id="returns string immediately on first attempt with no sleep"), |
| 84 | + pytest.param("success", 2, 42, None, None, 60, 1.05, 1.75, |
| 85 | + id="returns integer after 1 retry with ~1s backoff"), |
| 86 | + pytest.param("success", 3, {"key": "val"}, None, None, 60, 3.10, 3.90, |
| 87 | + id="returns dict after 2 retries with linear backoff (1s+2s)"), |
| 88 | + pytest.param("success", 5, [1, 2], None, None, 60, 10.25, 11.75, |
| 89 | + id="returns list after 4 retries with linear backoff (1s+2s+3s+4s)"), |
| 90 | + pytest.param("success", 1, None, None, None, 60, 0.0, 0.0, |
| 91 | + id="returns None as valid result immediately (None is acceptable)"), |
| 92 | + pytest.param("success", 5, "ok", None, None, 200, 10.2, 13.0, |
| 93 | + id="verifies linear backoff increase over 4 retries"), |
| 94 | + pytest.param("success", 11, "ok", None, None, 200, 55.5, 62.5, |
| 95 | + id="verifies linear backoff approaching 10s cap over 10 retries"), |
| 96 | + pytest.param("success", 15, "ok", None, None, 200, 95.7, 105.5, |
| 97 | + id="verifies backoff is capped at 10s after 10th retry"), |
| 98 | + pytest.param("timeout", None, None, TimeoutError, "Timed out after", 1, 1, None, |
| 99 | + id="raises TimeoutError after 1 second of continuous retries"), |
| 100 | + pytest.param("timeout", None, None, TimeoutError, "Timed out after", 5, 5, None, |
| 101 | + id="raises TimeoutError after 5 seconds of continuous retries"), |
| 102 | + pytest.param("timeout", None, None, TimeoutError, "Timed out after", 15, 15, None, |
| 103 | + id="raises TimeoutError after 15 seconds of continuous retries"), |
| 104 | + pytest.param("halt", 1, None, ValueError, "halt error", 60, None, None, |
| 105 | + id="raises ValueError immediately when halt error on first attempt"), |
| 106 | + pytest.param("halt", 2, None, ValueError, "halt error", 60, None, None, |
| 107 | + id="raises ValueError after 1 retry when halt error on second attempt"), |
| 108 | + pytest.param("halt", 3, None, ValueError, "halt error", 60, None, None, |
| 109 | + id="raises ValueError after 2 retries when halt error on third attempt"), |
| 110 | + pytest.param("unexpected", 1, None, RuntimeError, "unexpected", 60, None, None, |
| 111 | + id="raises RuntimeError immediately on unexpected exception"), |
| 112 | + pytest.param("unexpected", 3, None, RuntimeError, "unexpected", 60, None, None, |
| 113 | + id="raises RuntimeError after 2 retries on unexpected exception"), |
| 114 | + ], |
| 115 | +) |
| 116 | +def test_poll_behavior( |
| 117 | + scenario: Literal["success", "timeout", "halt", "unexpected"], |
| 118 | + attempts: Optional[int], |
| 119 | + result_value: Any, |
| 120 | + exception_type: Optional[Type[Exception]], |
| 121 | + exception_msg: Optional[str], |
| 122 | + timeout: int, |
| 123 | + min_time: Optional[float], |
| 124 | + max_time: Optional[float], |
| 125 | +) -> None: |
| 126 | + """ |
| 127 | + Comprehensive test for poll function covering all scenarios: |
| 128 | + - Success cases with various return types and retry counts |
| 129 | + - Backoff timing behavior (linear increase, 10s cap) |
| 130 | + - Timeout behavior |
| 131 | + - Halting errors |
| 132 | + - Unexpected exceptions |
| 133 | + """ |
| 134 | + clock: FakeClock = FakeClock() |
| 135 | + call_count: int = 0 |
| 136 | + |
| 137 | + def fn() -> tuple[Any, Optional[RetryError]]: |
| 138 | + nonlocal call_count |
| 139 | + call_count += 1 |
| 140 | + |
| 141 | + if scenario == "success": |
| 142 | + if call_count < attempts: |
| 143 | + return None, RetryError.continues(f"attempt {call_count}") |
| 144 | + return result_value, None |
| 145 | + |
| 146 | + elif scenario == "timeout": |
| 147 | + return None, RetryError.continues("retrying") |
| 148 | + |
| 149 | + elif scenario == "halt": |
| 150 | + if call_count < attempts: |
| 151 | + return None, RetryError.continues("retrying") |
| 152 | + return None, RetryError.halt(ValueError(exception_msg)) |
| 153 | + |
| 154 | + elif scenario == "unexpected": |
| 155 | + if call_count < attempts: |
| 156 | + return None, RetryError.continues("retrying") |
| 157 | + raise RuntimeError(exception_msg) |
| 158 | + |
| 159 | + if scenario == "success": |
| 160 | + result: Any = poll(fn, timeout=timedelta(seconds=timeout), clock=clock) |
| 161 | + assert result == result_value |
| 162 | + assert call_count == attempts |
| 163 | + if min_time is not None: |
| 164 | + assert clock.time() >= min_time |
| 165 | + if max_time is not None: |
| 166 | + assert clock.time() <= max_time |
| 167 | + else: |
| 168 | + with pytest.raises(exception_type) as exc_info: |
| 169 | + poll(fn, timeout=timedelta(seconds=timeout), clock=clock) |
| 170 | + |
| 171 | + assert exception_msg in str(exc_info.value) |
| 172 | + assert call_count >= 1 |
| 173 | + |
| 174 | + if scenario == "timeout": |
| 175 | + assert clock.time() >= min_time - 1 |
| 176 | + elif scenario in ("halt", "unexpected"): |
| 177 | + assert call_count == attempts |
0 commit comments