Skip to content

Commit e0fec3c

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

16 files changed

Lines changed: 619 additions & 26 deletions

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.utils.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/__init__.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,61 @@
1414

1515
from __future__ import absolute_import
1616

17-
from .create_from_yaml import (
18-
FailToCreateError, create_from_dict, create_from_yaml,
19-
create_from_yaml_single_item,
20-
)
17+
import typing as _typing
18+
2119
from .retry import (Backoff, DEFAULT_BACKOFF, DEFAULT_RETRY,
2220
DEFAULT_RETRY_AFTER_BACKOFF, on_error,
2321
on_retry_after_error, retry_on_conflict,
2422
is_conflict, is_retry_after_response,
2523
is_too_many_requests, retry_after_backoff,
2624
retry_after_max_retries, retry_after_seconds)
25+
26+
27+
__all__ = [
28+
"FailToCreateError",
29+
"create_from_dict",
30+
"create_from_yaml",
31+
"create_from_yaml_single_item",
32+
"Backoff",
33+
"DEFAULT_BACKOFF",
34+
"DEFAULT_RETRY",
35+
"DEFAULT_RETRY_AFTER_BACKOFF",
36+
"on_error",
37+
"on_retry_after_error",
38+
"retry_on_conflict",
39+
"is_conflict",
40+
"is_retry_after_response",
41+
"is_too_many_requests",
42+
"retry_after_backoff",
43+
"retry_after_max_retries",
44+
"retry_after_seconds",
45+
]
46+
47+
48+
# REST imports retry while client is initializing, so defer client-dependent utilities.
49+
if _typing.TYPE_CHECKING:
50+
from .create_from_yaml import (
51+
FailToCreateError, create_from_dict, create_from_yaml,
52+
create_from_yaml_single_item,
53+
)
54+
else:
55+
from importlib import import_module
56+
57+
_exports = {
58+
"FailToCreateError": ".create_from_yaml",
59+
"create_from_dict": ".create_from_yaml",
60+
"create_from_yaml": ".create_from_yaml",
61+
"create_from_yaml_single_item": ".create_from_yaml",
62+
}
63+
64+
def __getattr__(name: str) -> object:
65+
if (module_name := _exports.get(name)) is None:
66+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
67+
module = import_module(module_name, __name__)
68+
for export, export_module in _exports.items():
69+
if export_module == module_name:
70+
globals()[export] = getattr(module, export)
71+
return globals()[name]
72+
73+
def __dir__() -> list[str]:
74+
return sorted(globals().keys() | _exports.keys())

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/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)