Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: handle timeout errors in API requests (DEV-2513) #457

Merged
merged 5 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/dsp_tools/fast_xmlupload/process_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from dsp_tools.models.exceptions import UserError
from dsp_tools.utils.logging import get_logger

from dsp_tools.utils.shared import try_api_call

logger = get_logger(__name__, filesize_mb=100, backupcount=36)
sipi_container: Optional[Container] = None
export_moving_image_frames_script: Optional[Path] = None
Expand All @@ -33,10 +35,11 @@ def _get_export_moving_image_frames_script() -> None:
user_folder.mkdir(parents=True, exist_ok=True)
global export_moving_image_frames_script
export_moving_image_frames_script = user_folder / "export-moving-image-frames.sh"
script_text = requests.get(
"https://github.com/dasch-swiss/dsp-api/raw/main/sipi/scripts/export-moving-image-frames.sh",
timeout=10,
).text
script_text_response = try_api_call(
action=requests.get,
url="https://github.com/dasch-swiss/dsp-api/raw/main/sipi/scripts/export-moving-image-frames.sh",
)
script_text = script_text_response.text
with open(export_moving_image_frames_script, "w", encoding="utf-8") as f:
f.write(script_text)

Expand Down
7 changes: 4 additions & 3 deletions src/dsp_tools/fast_xmlupload/upload_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from dsp_tools.models.connection import Connection
from dsp_tools.models.exceptions import BaseError
from dsp_tools.utils.logging import get_logger
from dsp_tools.utils.shared import login
from dsp_tools.utils.shared import try_api_call, login

logger = get_logger(__name__)

Expand Down Expand Up @@ -110,11 +110,12 @@ def _upload_without_processing(
try:
with open(file, "rb") as bitstream:
try:
response_upload = requests.post(
response_upload = try_api_call(
action=requests.post,
initial_timeout=8 * 60,
url=f"{regex.sub(r'/$', '', sipi_url)}/upload_without_processing",
headers={"Authorization": f"Bearer {con.get_token()}"},
files={"file": bitstream},
timeout=8 * 60,
)
except Exception: # pylint: disable=broad-exception-caught
err_msg = f"An exception was raised while calling the /upload_without_processing route for {file}"
Expand Down
20 changes: 10 additions & 10 deletions src/dsp_tools/models/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def login(self, email: str, password: str) -> None:
self._server + "/v2/authentication",
headers={"Content-Type": "application/json; charset=UTF-8"},
data=jsondata,
timeout=10,
timeout=20,
)
check_for_api_error(response)
result = response.json()
Expand Down Expand Up @@ -105,7 +105,7 @@ def logout(self) -> None:
response = requests.delete(
self._server + "/v2/authentication",
headers={"Authorization": "Bearer " + self._token},
timeout=10,
timeout=20,
)
check_for_api_error(response)
self._token = None
Expand Down Expand Up @@ -192,19 +192,19 @@ def get(self, path: str, headers: Optional[dict[str, str]] = None) -> dict[str,
path = "/" + path
if not self._token:
if not headers:
response = requests.get(self._server + path, timeout=10)
response = requests.get(self._server + path, timeout=20)
else:
response = requests.get(self._server + path, headers, timeout=10)
response = requests.get(self._server + path, headers, timeout=20)
else:
if not headers:
response = requests.get(
self._server + path,
headers={"Authorization": "Bearer " + self._token},
timeout=10,
timeout=20,
)
else:
headers["Authorization"] = "Bearer " + self._token
response = requests.get(self._server + path, headers, timeout=10)
response = requests.get(self._server + path, headers, timeout=20)

check_for_api_error(response)
json_response = response.json()
Expand All @@ -225,14 +225,14 @@ def put(self, path: str, jsondata: Optional[str] = None, content_type: str = "ap
response = requests.put(
self._server + path,
headers={"Authorization": "Bearer " + self._token},
timeout=10,
timeout=20,
)
else:
response = requests.put(
self._server + path,
headers={"Content-Type": content_type + "; charset=UTF-8", "Authorization": "Bearer " + self._token},
data=jsondata,
timeout=10,
timeout=20,
)
check_for_api_error(response)
result = response.json()
Expand All @@ -252,14 +252,14 @@ def delete(self, path: str, params: Optional[any] = None):
self._server + path,
headers={"Authorization": "Bearer " + self._token},
params=params,
timeout=10,
timeout=20,
)

else:
response = requests.delete(
self._server + path,
headers={"Authorization": "Bearer " + self._token},
timeout=10,
timeout=20,
)
check_for_api_error(response)
result = response.json()
Expand Down
9 changes: 6 additions & 3 deletions src/dsp_tools/models/sipi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from dsp_tools.models.connection import check_for_api_error

from dsp_tools.utils.shared import try_api_call


class Sipi:
"""Represents the Sipi instance"""
Expand All @@ -29,11 +31,12 @@ def upload_bitstream(self, filepath: str) -> dict[Any, Any]:
files = {
"file": (os.path.basename(filepath), bitstream_file),
}
response = requests.post(
self.sipi_server + "/upload",
response = try_api_call(
action=requests.post,
initial_timeout=5 * 60,
url=self.sipi_server + "/upload",
headers={"Authorization": "Bearer " + self.token},
files=files,
timeout=5 * 60,
)
check_for_api_error(response)
res: dict[Any, Any] = response.json()
Expand Down
48 changes: 47 additions & 1 deletion src/dsp_tools/utils/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import regex
from lxml import etree
from requests import ReadTimeout, RequestException
import requests
from urllib3.exceptions import ReadTimeoutError

from dsp_tools.models.connection import Connection
from dsp_tools.models.exceptions import BaseError, UserError
Expand Down Expand Up @@ -52,14 +54,58 @@ def login(
return con


def try_api_call(
action: Callable[..., Any],
*args: Any,
initial_timeout: int = 10,
**kwargs: Any,
) -> Any:
"""
Function that tries 7 times to execute an API call.
Every time, the previous timeout is increased by 10 seconds.
Use this only for actions that can be retried without side effects.

Args:
action: one of requests.get(), requests.post(), requests.put(), requests.delete()
initial_timeout: Timeout to start with. Defaults to 10 seconds.

Returns:
_description_
"""
if action not in (requests.get, requests.post, requests.put, requests.delete):
raise BaseError("This function can only be used with the methods get, post, put, and delete of requests.")
action_as_str = f"action='{action}', args='{args}', kwargs='{kwargs}'"
timeout = initial_timeout
for i in range(7):
try:
if args and not kwargs:
return action(*args, timeout=timeout)
elif kwargs and not args:
return action(**kwargs, timeout=timeout)
elif args and kwargs:
return action(*args, **kwargs, timeout=timeout)
else:
return action(timeout=timeout)
except (TimeoutError, ReadTimeout, ReadTimeoutError):
timeout += 10
msg = f"Timeout Error: Retry request with timeout {timeout} in {2 ** i} seconds..."
print(f"{datetime.now().isoformat()}: {msg}")
logger.error(f"{msg} {action_as_str} (retry-counter i={i})", exc_info=True)
time.sleep(2**i)
continue

logger.error("Permanently unable to execute the API call. See logs for more details.")
raise BaseError("Permanently unable to execute the API call. See logs for more details.")


def try_network_action(
action: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""
Helper method that tries 7 times to execute an action.
If a ConnectionError, a requests.exceptions.RequestException, or a non-permanent BaseError occors,
If a timeout error, a ConnectionError, a requests.exceptions.RequestException, or a non-permanent BaseError occors,
it waits and retries.
The waiting times are 1, 2, 4, 8, 16, 32, 64 seconds.
If another exception occurs, it escalates.
Expand Down
41 changes: 27 additions & 14 deletions src/dsp_tools/utils/stack_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from dsp_tools.models.exceptions import UserError
from dsp_tools.utils.logging import get_logger

from dsp_tools.utils.shared import try_api_call

logger = get_logger(__name__)


Expand Down Expand Up @@ -121,7 +123,11 @@ def _get_sipi_docker_config_lua(self) -> None:
Raises:
UserError: if max_file_size is set but cannot be injected into sipi.docker-config.lua
"""
docker_config_lua_text = requests.get(f"{self.__url_prefix}sipi/config/sipi.docker-config.lua", timeout=10).text
docker_config_lua_response = try_api_call(
action=requests.get,
url=f"{self.__url_prefix}sipi/config/sipi.docker-config.lua",
)
docker_config_lua_text = docker_config_lua_response.text
if self.__stack_configuration.max_file_size:
max_post_size_regex = r"max_post_size ?= ?[\'\"]\d+M[\'\"]"
if not re.search(max_post_size_regex, docker_config_lua_text):
Expand Down Expand Up @@ -159,7 +165,11 @@ def _wait_for_fuseki(self) -> None:
"""
for _ in range(6 * 60):
try:
response = requests.get(url="http://0.0.0.0:3030/$/server", auth=("admin", "test"), timeout=10)
response = try_api_call(
action=requests.get,
url="http://0.0.0.0:3030/$/server",
auth=("admin", "test"),
)
if response.ok:
break
except Exception: # pylint: disable=broad-exception-caught
Expand All @@ -174,16 +184,18 @@ def _create_knora_test_repo(self) -> None:
Raises:
UserError: in case of failure
"""
repo_template = requests.get(
f"{self.__url_prefix}webapi/scripts/fuseki-repository-config.ttl.template",
timeout=10,
).text
repo_template_response = try_api_call(
action=requests.get,
url=f"{self.__url_prefix}webapi/scripts/fuseki-repository-config.ttl.template",
)
repo_template = repo_template_response.text
repo_template = repo_template.replace("@REPOSITORY@", "knora-test")
response = requests.post(
response = try_api_call(
action=requests.post,
initial_timeout=10,
url="http://0.0.0.0:3030/$/datasets",
files={"file": ("file.ttl", repo_template, "text/turtle; charset=utf8")},
auth=("admin", "test"),
timeout=10,
)
if not response.ok:
msg = (
Expand Down Expand Up @@ -215,17 +227,18 @@ def _load_data_into_repo(self) -> None:
("test_data/project_data/anything-data.ttl", "http://www.knora.org/data/0001/anything"),
]
for ttl_file, graph in ttl_files:
ttl_text_response = requests.get(self.__url_prefix + ttl_file, timeout=10)
if not ttl_text_response.ok:
ttl_response = try_api_call(action=requests.get, url=self.__url_prefix + ttl_file)
if not ttl_response.ok:
msg = f"Cannot start DSP-API: Error when retrieving '{self.__url_prefix + ttl_file}'"
logger.error(f"{msg}'. response = {vars(ttl_text_response)}")
logger.error(f"{msg}'. response = {vars(ttl_response)}")
raise UserError(msg)
ttl_text = ttl_text_response.text
response = requests.post(
ttl_text = ttl_response.text
response = try_api_call(
action=requests.post,
initial_timeout=10,
url=graph_prefix + graph,
files={"file": ("file.ttl", ttl_text, "text/turtle; charset: utf-8")},
auth=("admin", "test"),
timeout=10,
)
if not response.ok:
logger.error(f"Cannot start DSP-API: Error when creating graph '{graph}'. response = {vars(response)}")
Expand Down
Loading