From f2678cd2ff802c4ea291606ff8f3c8ee0489edcf Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Tue, 8 Sep 2026 15:29:25 -0400 Subject: [PATCH 1/6] Make retry settings configurable on Session Accept max_retries and max_retry_backoff as constructor keyword arguments, defaulting to the existing module-level constants so behavior is unchanged. A max_retries of zero disables retry. Co-Authored-By: Claude Opus 5 (1M context) --- planet/http.py | 35 +++++++++++++++++++++++++++-- tests/unit/test_http.py | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/planet/http.py b/planet/http.py index f971bb8d2..8a718b0a1 100644 --- a/planet/http.py +++ b/planet/http.py @@ -226,6 +226,21 @@ class Session(BaseSession): ... >>> asyncio.run(main()) + ``` + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... # customize the retry behavior + ... async with Session(max_retries=10, max_retry_backoff=32) as sess: + ... # communicate with services here + ... pass + ... + >>> asyncio.run(main()) + ``` """ @@ -233,12 +248,18 @@ def __init__( self, auth: Optional[AuthType] = None, read_timeout_secs: Optional[float] = None, + max_retries: Optional[int] = None, + max_retry_backoff: Optional[float] = None, ): """Initialize a Session. Parameters: auth: Planet server authentication. read_timeout_secs: Maximum time to wait for data to be received. + max_retries: Maximum number of retries of a retryable request. + Zero disables retry. + max_retry_backoff: Maximum time, in seconds, to wait between + retries. """ if auth is None: auth = Auth.from_user_default_session() @@ -246,6 +267,12 @@ def __init__( if read_timeout_secs is None: read_timeout_secs = DEFAULT_READ_TIMEOUT_SECS + if max_retries is None: + max_retries = MAX_RETRIES + + if max_retry_backoff is None: + max_retry_backoff = MAX_RETRY_BACKOFF + LOGGER.info( f'Session read timeout set to {read_timeout_secs} seconds.') timeout = httpx.Timeout(10.0, read=read_timeout_secs) @@ -270,8 +297,12 @@ async def alog_response(*args, **kwargs): alog_response, self._raise_for_status ] - self.max_retries = MAX_RETRIES - self.max_retry_backoff = MAX_RETRY_BACKOFF + self.max_retries = max_retries + self.max_retry_backoff = max_retry_backoff + + LOGGER.debug(f'Session retry set to a maximum of {self.max_retries} ' + f'retries with a maximum backoff of ' + f'{self.max_retry_backoff} seconds.') self._limiter = _Limiter(rate_limit=RATE_LIMIT, max_workers=MAX_ACTIVE) self.outcomes: Counter[str] = Counter() diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 762b66f8c..994d4daf3 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -269,6 +269,55 @@ async def test_func(): assert args == [(1, 64), (2, 64), (3, 64), (4, 64), (5, 64)] +@pytest.mark.anyio +async def test_session_retry_defaults(): + """Retry defaults to the module-level configuration""" + async with http.Session() as ps: + assert ps.max_retries == http.MAX_RETRIES + assert ps.max_retry_backoff == http.MAX_RETRY_BACKOFF + + +@respx.mock +@pytest.mark.anyio +async def test_session__retry_configured(): + """Retry configuration given to the Session is used by _retry""" + + async def test_func(): + # directly trigger the retry logic + raise exceptions.TooManyRequests + + with patch('planet.http.Session._calculate_wait') as mock_wait: + # let's not actually introduce a wait into the tests + mock_wait.return_value = 0 + + async with http.Session(max_retries=2, max_retry_backoff=8) as ps: + with pytest.raises(exceptions.TooManyRequests): + await ps._retry(test_func) + + calls = mock_wait.call_args_list + args = [c[0] for c in calls] + assert args == [(1, 8), (2, 8)] + + +@respx.mock +@pytest.mark.anyio +async def test_session__retry_disabled(): + """A max_retries of zero disables retry""" + + async def test_func(): + # directly trigger the retry logic + raise exceptions.TooManyRequests + + with patch('planet.http.Session._calculate_wait') as mock_wait: + mock_wait.return_value = 0 + + async with http.Session(max_retries=0) as ps: + with pytest.raises(exceptions.TooManyRequests): + await ps._retry(test_func) + + assert mock_wait.call_args_list == [] + + def test__calculate_wait(): max_retry_backoff = 20 wait_times = [ From fc5d1dcb02916bfacd8d7da74ddc7e23dba2bcc5 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Tue, 8 Sep 2026 15:41:35 -0400 Subject: [PATCH 2/6] Add retry options to the CLI Add --max-retries and --max-retry-backoff to the root command group and pass them through CliSession. Co-Authored-By: Claude Opus 5 (1M context) --- planet/cli/cli.py | 16 +++++++++++++ planet/cli/session.py | 12 +++++++--- tests/integration/test_data_cli.py | 19 +++++++++++++++ tests/unit/test_cli_session.py | 38 +++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 467b1e5b1..a87bd6cf5 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -21,6 +21,7 @@ import planet_auth_utils import planet from planet.cli import mosaics +from planet.http import MAX_RETRIES, MAX_RETRY_BACKOFF from . import auth, cmds, collect, data, destinations, orders, subscriptions, features @@ -38,6 +39,17 @@ default="warning", help=("Optional: set verbosity level to warning, info, or debug.\ Defaults to warning.")) +@click.option('--max-retries', + type=int, + default=MAX_RETRIES, + show_default=True, + help='Maximum number of retries of a retryable request. When ' + 'set to 0, requests are not retried.') +@click.option('--max-retry-backoff', + type=float, + default=MAX_RETRY_BACKOFF, + show_default=True, + help='Maximum time, in seconds, to wait between retries.') @planet_auth_utils.opt_profile() @planet_auth_utils.opt_client_id() @planet_auth_utils.opt_client_secret() @@ -46,6 +58,8 @@ def main(ctx, verbosity, quiet, + max_retries, + max_retry_backoff, auth_profile, auth_client_id, auth_client_secret, @@ -57,6 +71,8 @@ def main(ctx, # by means other than the `if` block below) ctx.ensure_object(dict) ctx.obj['QUIET'] = quiet + ctx.obj['MAX_RETRIES'] = max_retries + ctx.obj['MAX_RETRY_BACKOFF'] = max_retry_backoff _configure_cli_auth_ctx(ctx, auth_profile, diff --git a/planet/cli/session.py b/planet/cli/session.py index 8c1d3f6fc..8fec06e5d 100644 --- a/planet/cli/session.py +++ b/planet/cli/session.py @@ -7,13 +7,19 @@ class CliSession(Session): """Session with CLI-specific auth and identifying header""" def __init__(self, click_ctx=None, plsdk_auth=None): + _plsdk_auth = None + _max_retries = None + _max_retry_backoff = None + if click_ctx: _plsdk_auth = click_ctx.obj['PLSDK_AUTH'] - else: - _plsdk_auth = None + _max_retries = click_ctx.obj.get('MAX_RETRIES') + _max_retry_backoff = click_ctx.obj.get('MAX_RETRY_BACKOFF') if plsdk_auth: _plsdk_auth = plsdk_auth - super().__init__(_plsdk_auth) + super().__init__(_plsdk_auth, + max_retries=_max_retries, + max_retry_backoff=_max_retry_backoff) self._client.headers.update({'X-Planet-App': 'python-cli'}) diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py index 827bd7ab9..7d4620aca 100644 --- a/tests/integration/test_data_cli.py +++ b/tests/integration/test_data_cli.py @@ -434,6 +434,25 @@ def test_data_search_cmd_item_types(mock_bundles, assert result.exit_code == 2 +@respx.mock +def test_data_search_cmd_max_retries(mock_bundles): + """The --max-retries option is passed through to the session""" + route = respx.post(TEST_QUICKSEARCH_URL) + route.side_effect = [ + httpx.Response(HTTPStatus.TOO_MANY_REQUESTS, json={}), + httpx.Response(HTTPStatus.OK, json={'features': [{ + "key": "value" + }]}) + ] + + # retry disabled, so the first response is not retried + result = CliRunner().invoke( + cli.main, args=['--max-retries', '0', 'data', 'search', 'PSScene']) + + assert result.exit_code == 1 + assert route.call_count == 1 + + @respx.mock @pytest.mark.parametrize("geom_fixture", [('geom_geojson'), ('feature_geojson'), diff --git a/tests/unit/test_cli_session.py b/tests/unit/test_cli_session.py index 86ae35764..e5cfaa88d 100644 --- a/tests/unit/test_cli_session.py +++ b/tests/unit/test_cli_session.py @@ -14,13 +14,14 @@ from http import HTTPStatus import json +import click import httpx import respx import pytest # from planet.auth import _SecretFile -from planet import auth +from planet import auth, http from planet.cli import session TEST_URL = 'mock://mock.com' @@ -79,3 +80,38 @@ async def test_CliSession_auth_valid(test_valid_secretfile): # assert base64.b64decode(credentials) == b'clisessiontest:' credentials = received_request.headers['authorization'] assert credentials == 'api-key clisessiontest' + + +def _click_ctx(**obj): + """A click context carrying the given context object entries""" + return click.Context(click.Command('test'), obj=obj) + + +@pytest.mark.anyio +async def test_CliSession_retry_defaults(test_valid_secretfile): + """Retry defaults to the module-level configuration""" + async with session.CliSession() as sess: + assert sess.max_retries == http.MAX_RETRIES + assert sess.max_retry_backoff == http.MAX_RETRY_BACKOFF + + +@pytest.mark.anyio +async def test_CliSession_retry_from_ctx(test_valid_secretfile): + """Retry configuration is read from the click context""" + ctx = _click_ctx(PLSDK_AUTH=auth.Auth.from_key("clisessiontest"), + MAX_RETRIES=2, + MAX_RETRY_BACKOFF=8) + + async with session.CliSession(ctx) as sess: + assert sess.max_retries == 2 + assert sess.max_retry_backoff == 8 + + +@pytest.mark.anyio +async def test_CliSession_retry_ctx_unset(test_valid_secretfile): + """Retry falls back to the defaults when the context does not set it""" + ctx = _click_ctx(PLSDK_AUTH=auth.Auth.from_key("clisessiontest")) + + async with session.CliSession(ctx) as sess: + assert sess.max_retries == http.MAX_RETRIES + assert sess.max_retry_backoff == http.MAX_RETRY_BACKOFF From d2b976eff5fe58e0cda36e9add560759215dc5cd Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Tue, 8 Sep 2026 19:50:17 -0400 Subject: [PATCH 3/6] Jitter retry waits that hit the maximum backoff Threshold the base wait before adding jitter so thresholded waits are jittered instead of collapsing to one value. Co-Authored-By: Claude Opus 5 (1M context) --- planet/http.py | 21 +++++++++++++-------- tests/unit/test_http.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/planet/http.py b/planet/http.py index 8a718b0a1..d2360136d 100644 --- a/planet/http.py +++ b/planet/http.py @@ -49,6 +49,7 @@ ] MAX_RETRIES = 5 MAX_RETRY_BACKOFF = 64 # seconds +MAX_RETRY_JITTER = 1 # seconds DEFAULT_READ_TIMEOUT_SECS = 125.0 RATE_LIMIT = 10 # per second @@ -409,21 +410,25 @@ def _calculate_wait(num_tries, max_retry_backoff): """Calculates retry wait Base wait period is calculated as a exponential based on the number of - tries. Then, a random jitter of up to 999ms is added to the base wait - to avoid waves of requests in the case of multiple requests. Finally, - the wait is thresholded to the maximum retry backoff. + tries. The base wait is thresholded to the maximum retry backoff, less + room for jitter. Then, a random jitter of up to MAX_RETRY_JITTER is + added to the base wait to avoid waves of requests in the case of + multiple requests. - Because threshold is applied after jitter, calculations that hit - threshold will not have random jitter applied, they will simply result - in the threshold value being returned. + Because the threshold is applied before jitter, waits that hit the + threshold are jittered just like any other wait, and the maximum retry + backoff is never exceeded. Ref: * https://docs.planet.com/develop/apis/data/#api-mechanics * https://cloud.google.com/iot/docs/how-tos/exponential-backoff """ + # a backoff smaller than the jitter leaves no room for the full + # jitter, so the jitter is narrowed to fit within the backoff + jitter_secs = min(MAX_RETRY_JITTER, max_retry_backoff) + base_wait = min(2**num_tries, max_retry_backoff - jitter_secs) random_number_milliseconds = random.randint(0, 999) / 1000.0 - calc_wait = 2**num_tries + random_number_milliseconds - return min(calc_wait, max_retry_backoff) + return base_wait + jitter_secs * random_number_milliseconds async def request(self, method: str, diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 994d4daf3..d62a0a560 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -325,10 +325,43 @@ def test__calculate_wait(): for i in range(5) ] - # (min, max): 2**n to 2**n + 1, last entry hit threshold - expected_times = [2, 4, 8, 16, 20] + # (min, max): 2**n to 2**n + 1, last entry hit threshold, which reserves + # room for the jitter + expected_times = [2, 4, 8, 16, 19] for wait, expected in zip(wait_times, expected_times): # this doesn't really test the randomness but does test exponential # and threshold assert math.floor(wait) == expected + + +def test__calculate_wait_thresholded_is_jittered(): + """Waits that hit the threshold are jittered and never exceed it""" + max_retry_backoff = 20 + + # 2**5 is beyond the threshold, so every one of these waits is thresholded + wait_times = [ + http.Session._calculate_wait(5, max_retry_backoff) for _ in range(100) + ] + + assert all(19 <= wait <= max_retry_backoff for wait in wait_times) + + # the thresholded waits are jittered, not a constant + assert len(set(wait_times)) > 1 + + +def test__calculate_wait_backoff_smaller_than_jitter(): + """The jitter is narrowed to fit within a small maximum backoff""" + max_retry_backoff = 0.5 + + wait_times = [ + http.Session._calculate_wait(i + 1, max_retry_backoff) + for i in range(5) + ] + + assert all(0 <= wait <= max_retry_backoff for wait in wait_times) + + +def test__calculate_wait_backoff_zero(): + """A maximum backoff of zero waits not at all""" + assert http.Session._calculate_wait(1, 0) == 0 From 0ddf02a5332c39c37e3da0cbefc24ce3a2763fbd Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Tue, 8 Sep 2026 19:51:05 -0400 Subject: [PATCH 4/6] Make the retry jitter configurable Add max_retry_jitter to Session and --max-retry-jitter to the CLI. Co-Authored-By: Claude Opus 5 (1M context) --- planet/cli/cli.py | 10 +++++++++- planet/cli/session.py | 5 ++++- planet/http.py | 31 +++++++++++++++++++++++-------- tests/unit/test_cli_session.py | 6 +++++- tests/unit/test_http.py | 32 +++++++++++++++++++++++++++++--- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index a87bd6cf5..803a7aa77 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -21,7 +21,7 @@ import planet_auth_utils import planet from planet.cli import mosaics -from planet.http import MAX_RETRIES, MAX_RETRY_BACKOFF +from planet.http import MAX_RETRIES, MAX_RETRY_BACKOFF, MAX_RETRY_JITTER from . import auth, cmds, collect, data, destinations, orders, subscriptions, features @@ -50,6 +50,12 @@ default=MAX_RETRY_BACKOFF, show_default=True, help='Maximum time, in seconds, to wait between retries.') +@click.option('--max-retry-jitter', + type=float, + default=MAX_RETRY_JITTER, + show_default=True, + help='Maximum random time, in seconds, added to the wait ' + 'between retries. When set to 0, no jitter is added.') @planet_auth_utils.opt_profile() @planet_auth_utils.opt_client_id() @planet_auth_utils.opt_client_secret() @@ -60,6 +66,7 @@ def main(ctx, quiet, max_retries, max_retry_backoff, + max_retry_jitter, auth_profile, auth_client_id, auth_client_secret, @@ -73,6 +80,7 @@ def main(ctx, ctx.obj['QUIET'] = quiet ctx.obj['MAX_RETRIES'] = max_retries ctx.obj['MAX_RETRY_BACKOFF'] = max_retry_backoff + ctx.obj['MAX_RETRY_JITTER'] = max_retry_jitter _configure_cli_auth_ctx(ctx, auth_profile, diff --git a/planet/cli/session.py b/planet/cli/session.py index 8fec06e5d..6142a4cb8 100644 --- a/planet/cli/session.py +++ b/planet/cli/session.py @@ -10,16 +10,19 @@ def __init__(self, click_ctx=None, plsdk_auth=None): _plsdk_auth = None _max_retries = None _max_retry_backoff = None + _max_retry_jitter = None if click_ctx: _plsdk_auth = click_ctx.obj['PLSDK_AUTH'] _max_retries = click_ctx.obj.get('MAX_RETRIES') _max_retry_backoff = click_ctx.obj.get('MAX_RETRY_BACKOFF') + _max_retry_jitter = click_ctx.obj.get('MAX_RETRY_JITTER') if plsdk_auth: _plsdk_auth = plsdk_auth super().__init__(_plsdk_auth, max_retries=_max_retries, - max_retry_backoff=_max_retry_backoff) + max_retry_backoff=_max_retry_backoff, + max_retry_jitter=_max_retry_jitter) self._client.headers.update({'X-Planet-App': 'python-cli'}) diff --git a/planet/http.py b/planet/http.py index d2360136d..485ab9c35 100644 --- a/planet/http.py +++ b/planet/http.py @@ -236,7 +236,9 @@ class Session(BaseSession): >>> >>> async def main(): ... # customize the retry behavior - ... async with Session(max_retries=10, max_retry_backoff=32) as sess: + ... async with Session(max_retries=10, + ... max_retry_backoff=32, + ... max_retry_jitter=4) as sess: ... # communicate with services here ... pass ... @@ -251,6 +253,7 @@ def __init__( read_timeout_secs: Optional[float] = None, max_retries: Optional[int] = None, max_retry_backoff: Optional[float] = None, + max_retry_jitter: Optional[float] = None, ): """Initialize a Session. @@ -261,6 +264,8 @@ def __init__( Zero disables retry. max_retry_backoff: Maximum time, in seconds, to wait between retries. + max_retry_jitter: Maximum random time, in seconds, added to the + wait between retries. Zero disables jitter. """ if auth is None: auth = Auth.from_user_default_session() @@ -274,6 +279,9 @@ def __init__( if max_retry_backoff is None: max_retry_backoff = MAX_RETRY_BACKOFF + if max_retry_jitter is None: + max_retry_jitter = MAX_RETRY_JITTER + LOGGER.info( f'Session read timeout set to {read_timeout_secs} seconds.') timeout = httpx.Timeout(10.0, read=read_timeout_secs) @@ -300,10 +308,12 @@ async def alog_response(*args, **kwargs): self.max_retries = max_retries self.max_retry_backoff = max_retry_backoff + self.max_retry_jitter = max_retry_jitter LOGGER.debug(f'Session retry set to a maximum of {self.max_retries} ' f'retries with a maximum backoff of ' - f'{self.max_retry_backoff} seconds.') + f'{self.max_retry_backoff} seconds and a maximum jitter ' + f'of {self.max_retry_jitter} seconds.') self._limiter = _Limiter(rate_limit=RATE_LIMIT, max_workers=MAX_ACTIVE) self.outcomes: Counter[str] = Counter() @@ -396,7 +406,9 @@ async def _retry(self, func, *a, **kw): LOGGER.info(f'Try {num_tries}') LOGGER.info(f'Retrying: caught {type(e)}: {e}') wait_time = self._calculate_wait( - num_tries, self.max_retry_backoff) + num_tries, + self.max_retry_backoff, + self.max_retry_jitter) LOGGER.info(f'Retrying: sleeping {wait_time}s') await asyncio.sleep(wait_time) else: @@ -406,14 +418,14 @@ async def _retry(self, func, *a, **kw): return resp @staticmethod - def _calculate_wait(num_tries, max_retry_backoff): + def _calculate_wait(num_tries, max_retry_backoff, max_retry_jitter=None): """Calculates retry wait Base wait period is calculated as a exponential based on the number of tries. The base wait is thresholded to the maximum retry backoff, less - room for jitter. Then, a random jitter of up to MAX_RETRY_JITTER is - added to the base wait to avoid waves of requests in the case of - multiple requests. + room for jitter. Then, a random jitter of up to the maximum retry + jitter is added to the base wait to avoid waves of requests in the + case of multiple requests. Because the threshold is applied before jitter, waits that hit the threshold are jittered just like any other wait, and the maximum retry @@ -423,9 +435,12 @@ def _calculate_wait(num_tries, max_retry_backoff): * https://docs.planet.com/develop/apis/data/#api-mechanics * https://cloud.google.com/iot/docs/how-tos/exponential-backoff """ + if max_retry_jitter is None: + max_retry_jitter = MAX_RETRY_JITTER + # a backoff smaller than the jitter leaves no room for the full # jitter, so the jitter is narrowed to fit within the backoff - jitter_secs = min(MAX_RETRY_JITTER, max_retry_backoff) + jitter_secs = min(max_retry_jitter, max_retry_backoff) base_wait = min(2**num_tries, max_retry_backoff - jitter_secs) random_number_milliseconds = random.randint(0, 999) / 1000.0 return base_wait + jitter_secs * random_number_milliseconds diff --git a/tests/unit/test_cli_session.py b/tests/unit/test_cli_session.py index e5cfaa88d..787377464 100644 --- a/tests/unit/test_cli_session.py +++ b/tests/unit/test_cli_session.py @@ -93,6 +93,7 @@ async def test_CliSession_retry_defaults(test_valid_secretfile): async with session.CliSession() as sess: assert sess.max_retries == http.MAX_RETRIES assert sess.max_retry_backoff == http.MAX_RETRY_BACKOFF + assert sess.max_retry_jitter == http.MAX_RETRY_JITTER @pytest.mark.anyio @@ -100,11 +101,13 @@ async def test_CliSession_retry_from_ctx(test_valid_secretfile): """Retry configuration is read from the click context""" ctx = _click_ctx(PLSDK_AUTH=auth.Auth.from_key("clisessiontest"), MAX_RETRIES=2, - MAX_RETRY_BACKOFF=8) + MAX_RETRY_BACKOFF=8, + MAX_RETRY_JITTER=2) async with session.CliSession(ctx) as sess: assert sess.max_retries == 2 assert sess.max_retry_backoff == 8 + assert sess.max_retry_jitter == 2 @pytest.mark.anyio @@ -115,3 +118,4 @@ async def test_CliSession_retry_ctx_unset(test_valid_secretfile): async with session.CliSession(ctx) as sess: assert sess.max_retries == http.MAX_RETRIES assert sess.max_retry_backoff == http.MAX_RETRY_BACKOFF + assert sess.max_retry_jitter == http.MAX_RETRY_JITTER diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index d62a0a560..6ba063296 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -266,7 +266,8 @@ async def test_func(): calls = mock_wait.call_args_list args = [c[0] for c in calls] - assert args == [(1, 64), (2, 64), (3, 64), (4, 64), (5, 64)] + assert args == [(1, 64, 1), (2, 64, 1), (3, 64, 1), (4, 64, 1), + (5, 64, 1)] @pytest.mark.anyio @@ -275,6 +276,7 @@ async def test_session_retry_defaults(): async with http.Session() as ps: assert ps.max_retries == http.MAX_RETRIES assert ps.max_retry_backoff == http.MAX_RETRY_BACKOFF + assert ps.max_retry_jitter == http.MAX_RETRY_JITTER @respx.mock @@ -290,13 +292,15 @@ async def test_func(): # let's not actually introduce a wait into the tests mock_wait.return_value = 0 - async with http.Session(max_retries=2, max_retry_backoff=8) as ps: + async with http.Session(max_retries=2, + max_retry_backoff=8, + max_retry_jitter=2) as ps: with pytest.raises(exceptions.TooManyRequests): await ps._retry(test_func) calls = mock_wait.call_args_list args = [c[0] for c in calls] - assert args == [(1, 8), (2, 8)] + assert args == [(1, 8, 2), (2, 8, 2)] @respx.mock @@ -365,3 +369,25 @@ def test__calculate_wait_backoff_smaller_than_jitter(): def test__calculate_wait_backoff_zero(): """A maximum backoff of zero waits not at all""" assert http.Session._calculate_wait(1, 0) == 0 + + +def test__calculate_wait_jitter_configured(): + """The maximum jitter widens the range the wait is drawn from""" + max_retry_backoff = 64 + max_retry_jitter = 8 + + wait_times = [ + http.Session._calculate_wait(1, max_retry_backoff, max_retry_jitter) + for _ in range(100) + ] + + # 2**1 of base wait plus up to the maximum jitter + assert all(2 <= wait <= 2 + max_retry_jitter for wait in wait_times) + assert max(wait_times) > 2 + 1 # wider than the default jitter + + +def test__calculate_wait_jitter_zero(): + """A maximum jitter of zero gives a deterministic wait""" + wait_times = [http.Session._calculate_wait(i + 1, 20, 0) for i in range(5)] + + assert wait_times == [2, 4, 8, 16, 20] From cc78b05af2fad07b37728bf10739ef4aa994d448 Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 9 Sep 2026 21:10:50 -0400 Subject: [PATCH 5/6] Reword the comment on the mocked retry wait Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/test_http.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 6ba063296..3a3835988 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -239,7 +239,7 @@ async def test_session_request_retry(): httpx.Response(HTTPStatus.OK, json={}) ] - # let's not actually introduce a wait into the tests + # avoid introducing a real wait into the test ps.max_retry_backoff = 0 resp = await ps.request(method='GET', url=TEST_URL) @@ -257,7 +257,7 @@ async def test_func(): raise exceptions.TooManyRequests with patch('planet.http.Session._calculate_wait') as mock_wait: - # let's not actually introduce a wait into the tests + # avoid introducing a real wait into the test mock_wait.return_value = 0 async with http.Session() as ps: @@ -289,7 +289,7 @@ async def test_func(): raise exceptions.TooManyRequests with patch('planet.http.Session._calculate_wait') as mock_wait: - # let's not actually introduce a wait into the tests + # avoid introducing a real wait into the test mock_wait.return_value = 0 async with http.Session(max_retries=2, From 96b4884b2d2f1a3e25418992965b52766c84a89e Mon Sep 17 00:00:00 2001 From: asonnenschein Date: Wed, 9 Sep 2026 21:53:01 -0400 Subject: [PATCH 6/6] Reorder click options Co-Authored-By: Claude Opus 5 (1M context) --- planet/cli/cli.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 803a7aa77..f00288118 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -39,6 +39,10 @@ default="warning", help=("Optional: set verbosity level to warning, info, or debug.\ Defaults to warning.")) +@planet_auth_utils.opt_profile() +@planet_auth_utils.opt_client_id() +@planet_auth_utils.opt_client_secret() +@planet_auth_utils.opt_api_key() @click.option('--max-retries', type=int, default=MAX_RETRIES, @@ -56,21 +60,17 @@ show_default=True, help='Maximum random time, in seconds, added to the wait ' 'between retries. When set to 0, no jitter is added.') -@planet_auth_utils.opt_profile() -@planet_auth_utils.opt_client_id() -@planet_auth_utils.opt_client_secret() -@planet_auth_utils.opt_api_key() @cmds.translate_exceptions def main(ctx, verbosity, quiet, - max_retries, - max_retry_backoff, - max_retry_jitter, auth_profile, auth_client_id, auth_client_secret, - auth_api_key): + auth_api_key, + max_retries, + max_retry_backoff, + max_retry_jitter): """Planet SDK for Python CLI""" _configure_logging(verbosity)