Skip to content

Commit 0f0afb6

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

18 files changed

Lines changed: 528 additions & 30 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/create_from_yaml.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818

1919
import yaml
2020

21-
from kubernetes.aio import client
22-
2321

2422
async def create_from_yaml(
2523
k8s_client,
@@ -115,6 +113,8 @@ async def create_from_dict(
115113
processing of the request.
116114
Valid values are: - All: all dry run stages will be processed
117115
"""
116+
from kubernetes.aio import client
117+
118118
api_exceptions = []
119119
k8s_objects = []
120120

@@ -156,6 +156,8 @@ async def create_from_yaml_single_item(
156156
verbose=False,
157157
namespace="default",
158158
**kwargs):
159+
from kubernetes.aio import client
160+
159161
group, _, version = yml_object["apiVersion"].partition("/")
160162
if version == "":
161163
version = group

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

kubernetes/client/rest.py

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020
from urllib.parse import urlparse
2121

2222
import urllib3
23+
from urllib3.util.retry import Retry
2324

25+
from kubernetes.utils.retry import (
26+
is_retry_after_response,
27+
on_retry_after_error,
28+
retry_after_backoff,
29+
)
2430
from kubernetes.client.exceptions import ApiException, ApiValueError
2531

2632
SUPPORTED_SOCKS_PROXIES = {"socks5", "socks5h", "socks4", "socks4a"}
@@ -105,6 +111,8 @@ def getheader(self, name, default=None):
105111
class RESTClientObject:
106112

107113
def __init__(self, configuration) -> None:
114+
self.configuration = configuration
115+
108116
# urllib3.PoolManager will pass all kw parameters to connectionpool
109117
# https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/poolmanager.py#L75 # noqa: E501
110118
# https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/connectionpool.py#L680 # noqa: E501
@@ -217,6 +225,32 @@ def request(
217225
read=_request_timeout[1]
218226
)
219227

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(
235+
getattr(self.configuration, 'retries', None))
236+
237+
def read_request(check_retry_status=False):
238+
kwargs = {}
239+
if read_retries is not None:
240+
kwargs['retries'] = read_retries
241+
response = self.pool_manager.request(
242+
method,
243+
url,
244+
fields={},
245+
timeout=timeout,
246+
headers=headers,
247+
preload_content=False,
248+
**kwargs
249+
)
250+
if check_retry_status:
251+
self._raise_retry_after_response(response)
252+
return response
253+
220254
try:
221255
# For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
222256
if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
@@ -310,16 +344,67 @@ def request(
310344
raise ApiException(status=0, reason=msg)
311345
# For `GET`, `HEAD`
312346
else:
313-
r = self.pool_manager.request(
314-
method,
315-
url,
316-
fields={},
317-
timeout=timeout,
318-
headers=headers,
319-
preload_content=False
320-
)
347+
if client_go_read_retries:
348+
backoff = retry_after_backoff(
349+
getattr(self.configuration, 'retries', None),
350+
getattr(self.configuration, 'client_go_retry_backoff', None),
351+
)
352+
r = on_retry_after_error(
353+
backoff, self._is_read_retryable,
354+
lambda: read_request(True))
355+
else:
356+
r = read_request()
321357
except urllib3.exceptions.SSLError as e:
322358
msg = "\n".join([type(e).__name__, str(e)])
323359
raise ApiException(status=0, reason=msg)
324360

325361
return RESTResponse(r)
362+
363+
@classmethod
364+
def _is_read_retryable(cls, error):
365+
return is_retry_after_response(error)
366+
367+
@staticmethod
368+
def _retry_after_error(response):
369+
error = ApiException(status=response.status, reason=response.reason)
370+
error.headers = response.getheaders()
371+
return error
372+
373+
@classmethod
374+
def _raise_retry_after_response(cls, response):
375+
error = cls._retry_after_error(response)
376+
if not is_retry_after_response(error):
377+
return
378+
cls._read_and_close_retry_response(response)
379+
raise error
380+
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+
394+
@staticmethod
395+
def _urllib3_retries_without_status(retries):
396+
if retries is False:
397+
return False
398+
if retries is None:
399+
retries = Retry.DEFAULT
400+
elif retries is True:
401+
retries = Retry.DEFAULT
402+
elif isinstance(retries, int):
403+
retries = Retry.from_int(retries)
404+
if isinstance(retries, Retry):
405+
return retries.new(
406+
status=0,
407+
status_forcelist=(),
408+
respect_retry_after_header=False,
409+
)
410+
return retries

0 commit comments

Comments
 (0)