Skip to content

Commit dbccac0

Browse files
committed
fixup! rest: apply client-go retry generation patches
Signed-off-by: Dr. Stefan Schimanski <[email protected]>
1 parent 50f6582 commit dbccac0

15 files changed

Lines changed: 285 additions & 166 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/rest.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@
2121
import aiohttp
2222
import aiohttp_retry
2323

24-
from kubernetes.aio.client.exceptions import ApiException, ApiValueError
25-
from kubernetes.aio.utils.retry import (
24+
from kubernetes.aio.client._retry import (
2625
is_retry_after_response,
2726
on_retry_after_error,
2827
retry_after_backoff,
2928
)
29+
from kubernetes.aio.client.exceptions import ApiException, ApiValueError
3030

3131
RESTResponseType = aiohttp.ClientResponse
3232

@@ -289,7 +289,16 @@ async def request(
289289
self.pool_manager = self._create_pool_manager()
290290
pool_manager = self.pool_manager
291291

292-
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+
):
293302
if self.retry_client is None:
294303
self.retry_client = aiohttp_retry.RetryClient(
295304
client_session=self.pool_manager,
@@ -303,10 +312,7 @@ async def read_request(check_retry_status=False):
303312
self._raise_retry_after_response(response)
304313
return response
305314

306-
if (
307-
method in ['GET', 'HEAD']
308-
and getattr(self.configuration, 'client_go_retries', False)
309-
):
315+
if client_go_read_retries:
310316
backoff = retry_after_backoff(
311317
getattr(self.configuration, 'retries', None),
312318
getattr(self.configuration, 'client_go_retry_backoff', None),

kubernetes/aio/test/test_generated_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ async def test_client_go_retry_retries_get_retry_after_response(self):
147147

148148
self.assertEqual([], namespaces.items)
149149
self.assertEqual(2, len(self.requests))
150+
self.assertIsNone(self.api_client.rest_client.retry_client)
150151

151152
async def test_delete_job_accepts_job_and_status_responses(self):
152153
responses = (

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/rest.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
2222
import urllib3
2323
from urllib3.util.retry import Retry
2424

25-
from kubernetes.client.exceptions import ApiException, ApiValueError
26-
from kubernetes.utils.retry import (
25+
from kubernetes.client._retry import (
2726
is_retry_after_response,
2827
on_retry_after_error,
2928
retry_after_backoff,
3029
)
30+
from kubernetes.client.exceptions import ApiException, ApiValueError
3131

3232
SUPPORTED_SOCKS_PROXIES = {"socks5", "socks5h", "socks4", "socks4a"}
3333
RESTResponseType = urllib3.HTTPResponse
@@ -225,25 +225,27 @@ def request(
225225
read=_request_timeout[1]
226226
)
227227

228-
client_go_retries = getattr(self.configuration, 'client_go_retries', False)
229-
request_retries = None
230-
if client_go_retries:
231-
request_retries = self._urllib3_retries_without_status(
228+
client_go_read_retries = (
229+
method in ['GET', 'HEAD']
230+
and getattr(self.configuration, 'client_go_retries', False)
231+
)
232+
read_retries = None
233+
if client_go_read_retries:
234+
read_retries = self._urllib3_retries_without_status(
232235
getattr(self.configuration, 'retries', None))
233236

234-
def pool_request(*args, **kwargs):
235-
if request_retries is not None:
236-
kwargs['retries'] = request_retries
237-
return self.pool_manager.request(*args, **kwargs)
238-
239237
def read_request(check_retry_status=False):
240-
response = pool_request(
238+
kwargs = {}
239+
if read_retries is not None:
240+
kwargs['retries'] = read_retries
241+
response = self.pool_manager.request(
241242
method,
242243
url,
243244
fields={},
244245
timeout=timeout,
245246
headers=headers,
246-
preload_content=False
247+
preload_content=False,
248+
**kwargs
247249
)
248250
if check_retry_status:
249251
self._raise_retry_after_response(response)
@@ -279,7 +281,7 @@ def read_request(check_retry_status=False):
279281
request_body = None
280282
if body is not None:
281283
request_body = json.dumps(body)
282-
r = pool_request(
284+
r = self.pool_manager.request(
283285
method,
284286
url,
285287
body=request_body,
@@ -288,7 +290,7 @@ def read_request(check_retry_status=False):
288290
preload_content=False
289291
)
290292
elif content_type == 'application/x-www-form-urlencoded':
291-
r = pool_request(
293+
r = self.pool_manager.request(
292294
method,
293295
url,
294296
fields=post_params,
@@ -304,7 +306,7 @@ def read_request(check_retry_status=False):
304306
del headers['Content-Type']
305307
# Ensures that dict objects are serialized
306308
post_params = [(a, json.dumps(b)) if isinstance(b, dict) else (a,b) for a, b in post_params]
307-
r = pool_request(
309+
r = self.pool_manager.request(
308310
method,
309311
url,
310312
fields=post_params,
@@ -317,7 +319,7 @@ def read_request(check_retry_status=False):
317319
# other content types than JSON when `body` argument is
318320
# provided in serialized form.
319321
elif isinstance(body, str) or isinstance(body, bytes):
320-
r = pool_request(
322+
r = self.pool_manager.request(
321323
method,
322324
url,
323325
body=body,
@@ -327,7 +329,7 @@ def read_request(check_retry_status=False):
327329
)
328330
elif headers['Content-Type'].startswith('text/') and isinstance(body, bool):
329331
request_body = "true" if body else "false"
330-
r = pool_request(
332+
r = self.pool_manager.request(
331333
method,
332334
url,
333335
body=request_body,
@@ -342,7 +344,7 @@ def read_request(check_retry_status=False):
342344
raise ApiException(status=0, reason=msg)
343345
# For `GET`, `HEAD`
344346
else:
345-
if client_go_retries:
347+
if client_go_read_retries:
346348
backoff = retry_after_backoff(
347349
getattr(self.configuration, 'retries', None),
348350
getattr(self.configuration, 'client_go_retry_backoff', None),
@@ -373,12 +375,22 @@ def _raise_retry_after_response(cls, response):
373375
error = cls._retry_after_error(response)
374376
if not is_retry_after_response(error):
375377
return
376-
try:
377-
response.drain_conn()
378-
except Exception:
379-
response.release_conn()
378+
cls._read_and_close_retry_response(response)
380379
raise error
381380

381+
@staticmethod
382+
def _read_and_close_retry_response(response):
383+
try:
384+
try:
385+
content_length = int(response.getheaders().get(
386+
'Content-Length', '-1'))
387+
except (TypeError, ValueError):
388+
content_length = -1
389+
if content_length <= 2 << 10:
390+
response.read(2 << 10)
391+
finally:
392+
response.close()
393+
382394
@staticmethod
383395
def _urllib3_retries_without_status(retries):
384396
if retries is False:

0 commit comments

Comments
 (0)