Skip to content

Commit 7efe5a4

Browse files
committed
rest: apply client-go retry generation patches
Signed-off-by: Dr. Stefan Schimanski <[email protected]>
1 parent 9bc5eac commit 7efe5a4

18 files changed

Lines changed: 631 additions & 18 deletions

kubernetes/aio/client/_retry.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
import random
17+
from typing import Awaitable, Callable, TypeVar
18+
19+
from ._retry_base import (
20+
Backoff,
21+
_delay,
22+
is_retry_after_response,
23+
retry_after_backoff,
24+
retry_after_seconds,
25+
)
26+
27+
28+
T = TypeVar("T")
29+
30+
31+
async def on_retry_after_error(
32+
backoff: Backoff,
33+
retriable: Callable[[Exception], bool],
34+
fn: Callable[[], Awaitable[T]],
35+
sleep_func: Callable[[float], Awaitable[None]] = asyncio.sleep,
36+
random_func: Callable[[], float] = random.random,
37+
) -> T:
38+
"""Async implementation of client-go REST Retry-After sleep semantics."""
39+
40+
steps = backoff.steps
41+
duration = backoff.duration
42+
last_error = None
43+
while steps > 0:
44+
try:
45+
return await fn()
46+
except Exception as error:
47+
if not retriable(error):
48+
raise
49+
last_error = error
50+
51+
if steps == 1:
52+
break
53+
54+
delay, duration, steps = _delay(
55+
steps, duration, backoff, random_func)
56+
retry_after = retry_after_seconds(error)
57+
if retry_after is not None and retry_after > delay:
58+
delay = retry_after
59+
await sleep_func(delay)
60+
61+
raise last_error
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../base/retry.py

kubernetes/aio/client/configuration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,22 @@ def __init__(
355355
self.retries = retries
356356
"""Retry configuration
357357
"""
358+
self.client_go_retries = False
359+
"""Enable Kubernetes client-go-compatible retry semantics.
360+
361+
When enabled, GET and HEAD requests retry Retry-After responses.
362+
The retry ceiling is read from ``retries`` when set; otherwise it
363+
follows the client-go default of at most 10 retries.
364+
"""
365+
self.client_go_retry_backoff = None
366+
"""Backoff for Kubernetes client-go-compatible retries.
367+
368+
If unset, client-go-compatible GET and HEAD retries use the
369+
client-go default retry ceiling with no additional client-side
370+
delay beyond Retry-After. When set, ``retries`` still overrides
371+
the retry ceiling if it is not None.
372+
"""
373+
358374
self.trace_configs = trace_configs
359375
"""aiohttp.TraceConfig list forwarded to ClientSession for tracing.
360376
"""

kubernetes/aio/client/rest.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
import aiohttp
2222
import aiohttp_retry
2323

24+
from kubernetes.aio.client._retry import (
25+
is_retry_after_response,
26+
on_retry_after_error,
27+
retry_after_backoff,
28+
)
2429
from kubernetes.aio.client.exceptions import ApiException, ApiValueError
2530

2631
RESTResponseType = aiohttp.ClientResponse
@@ -284,14 +289,55 @@ async def request(
284289
self.pool_manager = self._create_pool_manager()
285290
pool_manager = self.pool_manager
286291

287-
if self._effective_retry_options is not None and method in ALLOW_RETRY_METHODS:
292+
client_go_read_retries = (
293+
method in ['GET', 'HEAD']
294+
and getattr(self.configuration, 'client_go_retries', False)
295+
)
296+
297+
if (
298+
self._effective_retry_options is not None
299+
and method in ALLOW_RETRY_METHODS
300+
and not client_go_read_retries
301+
):
288302
if self.retry_client is None:
289303
self.retry_client = aiohttp_retry.RetryClient(
290304
client_session=self.pool_manager,
291305
retry_options=self._effective_retry_options
292306
)
293307
pool_manager = self.retry_client
294308

295-
r = await pool_manager.request(**args)
309+
async def read_request(check_retry_status=False):
310+
response = await self.pool_manager.request(**args)
311+
if check_retry_status:
312+
self._raise_retry_after_response(response)
313+
return response
314+
315+
if client_go_read_retries:
316+
backoff = retry_after_backoff(
317+
getattr(self.configuration, 'retries', None),
318+
getattr(self.configuration, 'client_go_retry_backoff', None),
319+
)
320+
r = await on_retry_after_error(
321+
backoff, self._is_read_retryable, lambda: read_request(True))
322+
else:
323+
r = await pool_manager.request(**args)
296324

297325
return RESTResponse(r)
326+
327+
@classmethod
328+
def _is_read_retryable(cls, error):
329+
return is_retry_after_response(error)
330+
331+
@staticmethod
332+
def _retry_after_error(response):
333+
error = ApiException(status=response.status, reason=response.reason)
334+
error.headers = response.headers
335+
return error
336+
337+
@classmethod
338+
def _raise_retry_after_response(cls, response):
339+
error = cls._retry_after_error(response)
340+
if not is_retry_after_response(error):
341+
return
342+
response.release()
343+
raise error

kubernetes/aio/test/test_generated_api.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ async def asyncSetUp(self):
4646
'items': [],
4747
}
4848
self.response_status = 200
49+
self.response_headers = {}
50+
self.responses = None
4951
app = web.Application()
5052
app.router.add_route('*', '/{path:.*}', self._handle_request)
5153
self.runner = web.AppRunner(app)
@@ -91,7 +93,17 @@ async def _handle_request(self, request):
9193
}).encode() + b'\n')
9294
await response.write_eof()
9395
return response
94-
return web.json_response(self.response, status=self.response_status)
96+
if self.responses is not None:
97+
index = len(self.requests) - 1
98+
response, status, headers = self.responses[
99+
min(index, len(self.responses) - 1)
100+
]
101+
return web.json_response(response, status=status, headers=headers)
102+
return web.json_response(
103+
self.response,
104+
status=self.response_status,
105+
headers=self.response_headers,
106+
)
95107

96108
async def test_bearer_alias_supports_synchronous_token_refresh(self):
97109
self.configuration.api_key['authorization'] = 'expired-token'
@@ -123,6 +135,20 @@ async def refresh(configuration):
123135
self.requests[-1][0].headers['Authorization'],
124136
)
125137

138+
async def test_client_go_retry_retries_get_retry_after_response(self):
139+
self.configuration.client_go_retries = True
140+
self.configuration.retries = 1
141+
self.responses = [
142+
({'message': 'retry later'}, 429, {'Retry-After': '0'}),
143+
(self.response, 200, {}),
144+
]
145+
146+
namespaces = await CoreV1Api(self.api_client).list_namespace()
147+
148+
self.assertEqual([], namespaces.items)
149+
self.assertEqual(2, len(self.requests))
150+
self.assertIsNone(self.api_client.rest_client.retry_client)
151+
126152
async def test_delete_job_accepts_job_and_status_responses(self):
127153
responses = (
128154
(

kubernetes/aio/utils/retry_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
is_too_many_requests,
2121
on_error,
2222
on_retry_after_error,
23+
retry_after_max_retries,
2324
retry_after_seconds,
2425
retry_on_conflict,
2526
)
@@ -33,6 +34,12 @@ def __init__(self, status, headers=None):
3334
self.headers = headers or {}
3435

3536

37+
class RetryOptions:
38+
39+
def __init__(self, attempts):
40+
self.attempts = attempts
41+
42+
3643
class AioRetryTest(unittest.IsolatedAsyncioTestCase):
3744

3845
def test_default_retry_matches_client_go(self):
@@ -41,6 +48,10 @@ def test_default_retry_matches_client_go(self):
4148
Backoff(steps=5, duration=0.01, factor=1.0, jitter=0.1),
4249
)
4350

51+
def test_retry_after_backoff_uses_aio_retry_attempts(self):
52+
self.assertEqual(retry_after_max_retries(RetryOptions(attempts=3)), 2)
53+
self.assertEqual(retry_after_max_retries(RetryOptions(attempts=1)), 0)
54+
4455
def test_retry_after_seconds_parses_delay_seconds(self):
4556
error = FakeError(429, {"Retry-After": "7"})
4657

kubernetes/base/retry.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def retry_after_backoff(
7777
``None``, it is interpreted as the retry ceiling and overrides
7878
``backoff.steps``. ``False`` and ``0`` disable retries by returning a
7979
single-attempt backoff. Integer values and urllib3-style Retry objects with
80-
a ``total`` value are treated as the retry ceiling.
80+
a ``total`` value are treated as retry ceilings. aiohttp-retry-style
81+
options with an ``attempts`` value are treated as request attempt ceilings.
8182
"""
8283

8384
if backoff is None:
@@ -106,13 +107,22 @@ def retry_after_max_retries(retries: Any = None) -> int:
106107
if isinstance(retries, int):
107108
return max(0, retries)
108109

109-
total = getattr(retries, "total", None)
110-
if total is False:
110+
if hasattr(retries, "total"):
111+
total = getattr(retries, "total", None)
112+
if total is False:
113+
return 0
114+
if total is True or total is None:
115+
return 10
116+
if isinstance(total, int):
117+
return max(0, total)
118+
119+
attempts = getattr(retries, "attempts", None)
120+
if attempts is False:
111121
return 0
112-
if total is True or total is None:
122+
if attempts is True:
113123
return 10
114-
if isinstance(total, int):
115-
return max(0, total)
124+
if isinstance(attempts, int):
125+
return max(0, attempts - 1)
116126
return 10
117127

118128

kubernetes/client/_retry.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import random
16+
import time
17+
from typing import Callable, TypeVar
18+
19+
from ._retry_base import (
20+
Backoff,
21+
_delay,
22+
is_retry_after_response,
23+
retry_after_backoff,
24+
retry_after_seconds,
25+
)
26+
27+
28+
T = TypeVar("T")
29+
30+
31+
def on_retry_after_error(
32+
backoff: Backoff,
33+
retriable: Callable[[Exception], bool],
34+
fn: Callable[[], T],
35+
sleep_func: Callable[[float], None] = time.sleep,
36+
random_func: Callable[[], float] = random.random,
37+
) -> T:
38+
"""Run ``fn`` with client-go REST Retry-After sleep semantics."""
39+
40+
steps = backoff.steps
41+
duration = backoff.duration
42+
last_error = None
43+
while steps > 0:
44+
try:
45+
return fn()
46+
except Exception as error:
47+
if not retriable(error):
48+
raise
49+
last_error = error
50+
51+
if steps == 1:
52+
break
53+
54+
delay, duration, steps = _delay(
55+
steps, duration, backoff, random_func)
56+
retry_after = retry_after_seconds(error)
57+
if retry_after is not None and retry_after > delay:
58+
delay = retry_after
59+
sleep_func(delay)
60+
61+
raise last_error

kubernetes/client/_retry_base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../base/retry.py

kubernetes/client/configuration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,22 @@ def __init__(
357357
self.retries = retries
358358
"""Retry configuration
359359
"""
360+
self.client_go_retries = False
361+
"""Enable Kubernetes client-go-compatible retry semantics.
362+
363+
When enabled, GET and HEAD requests retry Retry-After responses.
364+
The retry ceiling is read from ``retries`` when set; otherwise it
365+
follows the client-go default of at most 10 retries.
366+
"""
367+
self.client_go_retry_backoff = None
368+
"""Backoff for Kubernetes client-go-compatible retries.
369+
370+
If unset, client-go-compatible GET and HEAD retries use the
371+
client-go default retry ceiling with no additional client-side
372+
delay beyond Retry-After. When set, ``retries`` still overrides
373+
the retry ceiling if it is not None.
374+
"""
375+
360376
# Enable client side validation
361377
self.client_side_validation = client_side_validation
362378

0 commit comments

Comments
 (0)