-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #358 from golemfactory/az/repeat-invoice-accept
Repeat API calls on timeouts
- Loading branch information
Showing
4 changed files
with
219 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import asyncio | ||
|
||
import aiohttp | ||
import pytest | ||
|
||
import ya_activity | ||
import ya_market | ||
import ya_payment | ||
|
||
from yapapi.rest.common import repeat_on_error, SuppressedExceptions, is_intermittent_error | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"max_tries, exceptions, calls_expected, expected_error", | ||
[ | ||
(1, [], 1, None), | ||
(1, [asyncio.TimeoutError()], 1, asyncio.TimeoutError), | ||
(1, [ya_activity.ApiException(408)], 1, ya_activity.ApiException), | ||
(1, [ya_activity.ApiException(500)], 1, ya_activity.ApiException), | ||
(1, [ValueError()], 1, ValueError), | ||
# | ||
(2, [], 1, None), | ||
(2, [asyncio.TimeoutError()], 2, None), | ||
(2, [ya_activity.ApiException(408)], 2, None), | ||
(2, [ya_market.ApiException(408)], 2, None), | ||
(2, [ya_payment.ApiException(408)], 2, None), | ||
(2, [ya_activity.ApiException(500)], 1, ya_activity.ApiException), | ||
(2, [aiohttp.ServerDisconnectedError()], 2, None), | ||
(2, [aiohttp.ClientOSError(32, "Broken pipe")], 2, None), | ||
(2, [aiohttp.ClientOSError(1132, "UnBroken pipe")], 1, aiohttp.ClientOSError), | ||
(2, [ValueError()], 1, ValueError), | ||
(2, [asyncio.TimeoutError()] * 2, 2, asyncio.TimeoutError), | ||
# | ||
(3, [], 1, None), | ||
(3, [asyncio.TimeoutError()], 2, None), | ||
(3, [ya_activity.ApiException(408)], 2, None), | ||
(3, [asyncio.TimeoutError()] * 2, 3, None), | ||
(3, [asyncio.TimeoutError()] * 3, 3, asyncio.TimeoutError), | ||
(3, [ya_activity.ApiException(500)], 1, ya_activity.ApiException), | ||
(3, [asyncio.TimeoutError(), ValueError()], 2, ValueError), | ||
], | ||
) | ||
@pytest.mark.asyncio | ||
async def test_repeat_on_error(max_tries, exceptions, calls_expected, expected_error): | ||
|
||
calls_made = 0 | ||
|
||
@repeat_on_error(max_tries=max_tries) | ||
async def request(): | ||
nonlocal calls_made, exceptions | ||
calls_made += 1 | ||
if exceptions: | ||
e = exceptions[0] | ||
exceptions = exceptions[1:] | ||
raise e | ||
return True | ||
|
||
try: | ||
await request() | ||
except Exception as e: | ||
assert expected_error is not None, f"Unexpected exception: {e}" | ||
assert isinstance(e, expected_error), f"Expected an {expected_error}, got {e}" | ||
assert ( | ||
calls_made == calls_expected | ||
), f"{calls_made} attempts were made, expected {calls_expected}" | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_suppressed_exceptions(): | ||
|
||
async with SuppressedExceptions(is_intermittent_error) as se: | ||
pass | ||
assert se.exception is None | ||
|
||
async with SuppressedExceptions(is_intermittent_error) as se: | ||
raise asyncio.TimeoutError() | ||
assert isinstance(se.exception, asyncio.TimeoutError) | ||
|
||
async with SuppressedExceptions(is_intermittent_error) as se: | ||
raise aiohttp.ClientOSError(32, "Broken pipe") | ||
assert isinstance(se.exception, aiohttp.ClientOSError) | ||
|
||
async with SuppressedExceptions(is_intermittent_error) as se: | ||
raise aiohttp.ServerDisconnectedError() | ||
assert isinstance(se.exception, aiohttp.ServerDisconnectedError) | ||
|
||
with pytest.raises(AssertionError): | ||
async with SuppressedExceptions(is_intermittent_error): | ||
raise AssertionError() | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_suppressed_exceptions_with_return(): | ||
async def success(): | ||
return "success" | ||
|
||
async def failure(): | ||
raise asyncio.TimeoutError() | ||
|
||
async def func(request): | ||
async with SuppressedExceptions(is_intermittent_error): | ||
return await request | ||
return "failure" # noqa | ||
|
||
assert await func(success()) == "success" | ||
assert await func(failure()) == "failure" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import asyncio | ||
import functools | ||
import logging | ||
from typing import Callable, Optional | ||
|
||
import aiohttp | ||
|
||
import ya_market | ||
import ya_activity | ||
import ya_payment | ||
|
||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def is_intermittent_error(e: Exception) -> bool: | ||
"""Check if `e` indicates an intermittent communication failure such as network timeout.""" | ||
|
||
is_timeout_exception = isinstance(e, asyncio.TimeoutError) or ( | ||
isinstance(e, (ya_activity.ApiException, ya_market.ApiException, ya_payment.ApiException)) | ||
and e.status in (408, 504) | ||
) | ||
|
||
return ( | ||
is_timeout_exception | ||
or isinstance(e, aiohttp.ServerDisconnectedError) | ||
# OS error with errno 32 is "Broken pipe" | ||
or (isinstance(e, aiohttp.ClientOSError) and e.errno == 32) | ||
) | ||
|
||
|
||
class SuppressedExceptions: | ||
"""An async context manager for suppressing exceptions satisfying given condition.""" | ||
|
||
exception: Optional[Exception] | ||
|
||
def __init__(self, condition: Callable[[Exception], bool], report_exceptions: bool = True): | ||
self._condition = condition | ||
self._report_exceptions = report_exceptions | ||
self.exception = None | ||
|
||
async def __aenter__(self) -> "SuppressedExceptions": | ||
return self | ||
|
||
async def __aexit__(self, exc_type, exc_value, traceback): | ||
if exc_value and self._condition(exc_value): | ||
self.exception = exc_value | ||
if self._report_exceptions: | ||
logger.debug( | ||
"Exception suppressed: %r", exc_value, exc_info=(exc_type, exc_value, traceback) | ||
) | ||
return True | ||
return False | ||
|
||
|
||
def repeat_on_error( | ||
max_tries: int, | ||
condition: Callable[[Exception], bool] = is_intermittent_error, | ||
interval: float = 0.0, | ||
): | ||
"""Decorate a function to repeat calls up to `max_tries` times when errors occur. | ||
Only exceptions satisfying the given `condition` will cause the decorated function | ||
to be retried. All remaining exceptions will fall through. | ||
""" | ||
|
||
def decorator(func): | ||
@functools.wraps(func) | ||
async def wrapper(*args, **kwargs): | ||
"""Make at most `max_tries` attempts to call `func`.""" | ||
|
||
for try_num in range(1, max_tries + 1): | ||
|
||
if try_num > 1: | ||
await asyncio.sleep(interval) | ||
|
||
async with SuppressedExceptions(condition, False) as se: | ||
return await func(*args, **kwargs) | ||
|
||
assert se.exception # noqa (unreachable) | ||
repeat = try_num < max_tries | ||
msg = f"API call timed out (attempt {try_num}/{max_tries}), " | ||
msg += f"retrying in {interval} s" if repeat else "giving up" | ||
# Don't print traceback if this was the last attempt, let the caller do it. | ||
logger.debug(msg, exc_info=repeat) | ||
if not repeat: | ||
raise se.exception | ||
|
||
return wrapper | ||
|
||
return decorator |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters