Repository: CS-SI/eodag
Pull request #982 (2024-01-11 14:10:21)

feat: add timeout error
handle timeout error in server mode


github-actions[bot]: ## Test Results
    4 files  ±0      4 suites  ±0   4m 8s :stopwatch: -1s
  449 tests +1    446 :white_check_mark: +1   3 :zzz: ±0  0 :x: ±0 
1 796 runs  +4  1 716 :white_check_mark: +2  80 :zzz: +2  0 :x: ±0 

Results for commit 9f12483d. ± Comparison against base commit 85498a93.

[test-results]:data:application/gzip;base64,H4sIAMf3n2UC/02Oyw7CIBBFf6Vh7YIB5OHPGGwhmdiHobAy/rsjlrbLc27mZN4s4hhWduvUpWNrwbzDUJLPuMyEQlkSNOU6Ktfovpa+r0of6okvUnIX0eNIgu8ipLSkzaQy/5pgnN6oNcHAof5N2y7OzcrnZL9ME2YC5iLQ63IQHKR/RCngak10lishAnAFA2ijgmWfL9Bk5OUIAQAA

github-actions[bot]: <strong>Code Coverage (Ubuntu)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `82%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 9f12483d2013abf321587f980422e1041d1674e8 </p>
github-actions[bot]: <strong>Code Coverage (Windows)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `76%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 9f12483d2013abf321587f980422e1041d1674e8 </p>
jlahovnik: > please implement `TimeoutError` in every piece of code where a timeout can occur (look for `timeout=` in methods args)

done

Patch:
diff --git a/eodag/plugins/authentication/openid_connect.py b/eodag/plugins/authentication/openid_connect.py
index c76df0bb..c6a7acf9 100644
--- a/eodag/plugins/authentication/openid_connect.py
+++ b/eodag/plugins/authentication/openid_connect.py
@@ -28,7 +28,7 @@ from requests.auth import AuthBase
 
 from eodag.plugins.authentication import Authentication
 from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT, parse_qs, repeatfunc, urlparse
-from eodag.utils.exceptions import AuthenticationError, MisconfiguredError
+from eodag.utils.exceptions import AuthenticationError, MisconfiguredError, TimeOutError
 
 if TYPE_CHECKING:
     from requests import PreparedRequest, Response
@@ -154,6 +154,8 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
             exchange_url = user_consent_response.url
         try:
             token = self.exchange_code_for_token(exchange_url, state)
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except Exception:
             import traceback as tb
 
diff --git a/eodag/plugins/authentication/qsauth.py b/eodag/plugins/authentication/qsauth.py
index 36f2ab4c..724fdf64 100644
--- a/eodag/plugins/authentication/qsauth.py
+++ b/eodag/plugins/authentication/qsauth.py
@@ -26,7 +26,7 @@ from requests.exceptions import RequestException
 
 from eodag.plugins.authentication import Authentication
 from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT
-from eodag.utils.exceptions import AuthenticationError
+from eodag.utils.exceptions import AuthenticationError, TimeOutError
 
 if TYPE_CHECKING:
     from requests import PreparedRequest
@@ -76,6 +76,8 @@ class HttpQueryStringAuth(Authentication):
                     auth=auth,
                 )
                 response.raise_for_status()
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
             except RequestException as e:
                 raise AuthenticationError(f"Could no authenticate: {str(e)}")
 
diff --git a/eodag/plugins/authentication/sas_auth.py b/eodag/plugins/authentication/sas_auth.py
index c1ed7bb0..c0d0e61b 100644
--- a/eodag/plugins/authentication/sas_auth.py
+++ b/eodag/plugins/authentication/sas_auth.py
@@ -26,7 +26,7 @@ from requests.auth import AuthBase
 
 from eodag.plugins.authentication.base import Authentication
 from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT, deepcopy, format_dict_items
-from eodag.utils.exceptions import AuthenticationError
+from eodag.utils.exceptions import AuthenticationError, TimeOutError
 
 if TYPE_CHECKING:
     from requests import PreparedRequest
@@ -67,6 +67,8 @@ class RequestsSASAuth(AuthBase):
                 )
                 response.raise_for_status()
                 signed_url = response.json().get(self.signed_url_key)
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
             except (requests.RequestException, JSONDecodeError, KeyError) as e:
                 raise AuthenticationError(f"Could no get signed url: {str(e)}")
             else:
diff --git a/eodag/plugins/authentication/token.py b/eodag/plugins/authentication/token.py
index dbd15041..12250f44 100644
--- a/eodag/plugins/authentication/token.py
+++ b/eodag/plugins/authentication/token.py
@@ -29,7 +29,7 @@ from urllib3 import Retry
 
 from eodag.plugins.authentication.base import Authentication
 from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT
-from eodag.utils.exceptions import AuthenticationError, MisconfiguredError
+from eodag.utils.exceptions import AuthenticationError, MisconfiguredError, TimeOutError
 
 if TYPE_CHECKING:
     from requests import PreparedRequest
@@ -98,6 +98,8 @@ class TokenAuth(Authentication):
                     **req_kwargs,
                 )
             response.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except RequestException as e:
             response_text = getattr(e.response, "text", "").strip()
             raise AuthenticationError(
diff --git a/eodag/plugins/download/aws.py b/eodag/plugins/download/aws.py
index 2eb0f07a..836e4ae2 100644
--- a/eodag/plugins/download/aws.py
+++ b/eodag/plugins/download/aws.py
@@ -58,7 +58,12 @@ from eodag.utils import (
     path_to_uri,
     rename_subfolder,
 )
-from eodag.utils.exceptions import AuthenticationError, DownloadError, NotAvailableError
+from eodag.utils.exceptions import (
+    AuthenticationError,
+    DownloadError,
+    NotAvailableError,
+    TimeOutError,
+)
 
 if TYPE_CHECKING:
     from boto3.resources.collection import ResourceCollection
@@ -294,7 +299,12 @@ class AwsDownload(Download):
                 **product.properties
             )
             logger.info("Fetching extra metadata from %s" % fetch_url)
-            resp = requests.get(fetch_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT)
+            try:
+                resp = requests.get(
+                    fetch_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT
+                )
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
             update_metadata = mtd_cfg_as_conversion_and_querypath(update_metadata)
             if fetch_format == "json":
                 json_resp = resp.json()
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index 0f5515c6..ae22844b 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -61,6 +61,7 @@ from eodag.utils.exceptions import (
     DownloadError,
     MisconfiguredError,
     NotAvailableError,
+    TimeOutError,
 )
 
 if TYPE_CHECKING:
@@ -167,6 +168,8 @@ class HTTPDownload(Download):
                 ordered_message = response.text
                 logger.debug(ordered_message)
                 logger.info("%s was ordered", product.properties["title"])
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
             except RequestException as e:
                 if e.response and hasattr(e.response, "content"):
                     error_message = f"{e.response.content.decode('utf-8')} - {e}"
@@ -359,6 +362,8 @@ class HTTPDownload(Download):
                             f"after order success. Please search and download {product} again"
                         )
 
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
             except RequestException as e:
                 logger.warning(
                     "%s order status could not be checked, request returned %s",
@@ -426,9 +431,7 @@ class HTTPDownload(Download):
             timeout: int,
             **kwargs: Dict[str, Any],
         ) -> None:
-            chunks = self._stream_download(
-                product, auth, progress_callback, wait, timeout, **kwargs
-            )
+            chunks = self._stream_download(product, auth, progress_callback, **kwargs)
 
             with open(fs_path, "wb") as fhandle:
                 for chunk in chunks:
@@ -557,9 +560,7 @@ class HTTPDownload(Download):
                 else:
                     pass
 
-        chunks = self._stream_download(
-            product, auth, progress_callback, wait, timeout, **kwargs
-        )
+        chunks = self._stream_download(product, auth, progress_callback, **kwargs)
         # start reading chunks to set product.headers
         first_chunk = next(chunks)
 
@@ -613,8 +614,6 @@ class HTTPDownload(Download):
         product: EOProduct,
         auth: Optional[PluginConfig] = None,
         progress_callback: Optional[ProgressCallback] = None,
-        wait: int = DEFAULT_DOWNLOAD_WAIT,
-        timeout: int = DEFAULT_DOWNLOAD_TIMEOUT,
         **kwargs: Dict[str, Any],
     ) -> Iterator[Any]:
         """
@@ -630,9 +629,6 @@ class HTTPDownload(Download):
                                   creation and update to give the user a
                                   feedback on the download progress
         :type progress_callback: :class:`~eodag.utils.ProgressCallback`
-        :param ordered_message: message to be used in case of error because
-                                the product is unavailable
-        :type ordered_message: str
         :param kwargs: additional arguments
         :type kwargs: dict
         """
@@ -688,6 +684,10 @@ class HTTPDownload(Download):
             try:
                 self.stream.raise_for_status()
 
+            except requests.exceptions.Timeout as exc:
+                raise TimeOutError(
+                    exc, timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT
+                ) from exc
             except RequestException as e:
                 self._process_exception(e, product, ordered_message)
             else:
@@ -799,6 +799,10 @@ class HTTPDownload(Download):
             ) as stream:
                 try:
                     stream.raise_for_status()
+                except requests.exceptions.Timeout as exc:
+                    raise TimeOutError(
+                        exc, timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT
+                    ) from exc
                 except RequestException as e:
                     raise_errors = True if len(assets_values) == 1 else False
                     self._handle_asset_exception(e, asset, raise_errors=raise_errors)
diff --git a/eodag/plugins/search/data_request_search.py b/eodag/plugins/search/data_request_search.py
index 14b70ee3..1dbfeec2 100644
--- a/eodag/plugins/search/data_request_search.py
+++ b/eodag/plugins/search/data_request_search.py
@@ -41,7 +41,7 @@ from eodag.utils import (
     deepcopy,
     string_to_jsonpath,
 )
-from eodag.utils.exceptions import NotAvailableError, RequestError
+from eodag.utils.exceptions import NotAvailableError, RequestError, TimeOutError
 
 if TYPE_CHECKING:
     from eodag.config import PluginConfig
@@ -258,6 +258,8 @@ class DataRequestSearch(Search):
                 url, json=request_body, headers=headers, timeout=HTTP_REQ_TIMEOUT
             )
             request_job.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except requests.RequestException as e:
             raise RequestError(
                 f"search job for product_type {product_type} could not be created: {str(e)}, {request_job.text}"
@@ -274,6 +276,8 @@ class DataRequestSearch(Search):
                 delete_url, headers=self.auth.headers, timeout=HTTP_REQ_TIMEOUT
             )
             delete_resp.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except requests.RequestException as e:
             raise RequestError(f"_cancel_request failed: {str(e)}")
 
@@ -285,6 +289,8 @@ class DataRequestSearch(Search):
                 status_url, headers=self.auth.headers, timeout=HTTP_REQ_TIMEOUT
             )
             status_resp.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except requests.RequestException as e:
             raise RequestError(f"_check_request_status failed: {str(e)}")
         else:
@@ -313,6 +319,8 @@ class DataRequestSearch(Search):
             return requests.get(
                 url, headers=self.auth.headers, timeout=HTTP_REQ_TIMEOUT
             ).json()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except requests.RequestException:
             logger.error(f"Result could not be retrieved for {url}")
         return {}
diff --git a/eodag/plugins/search/qssearch.py b/eodag/plugins/search/qssearch.py
index f398b44b..a4034626 100644
--- a/eodag/plugins/search/qssearch.py
+++ b/eodag/plugins/search/qssearch.py
@@ -60,7 +60,12 @@ from eodag.utils import (
     update_nested_dict,
     urlencode,
 )
-from eodag.utils.exceptions import AuthenticationError, MisconfiguredError, RequestError
+from eodag.utils.exceptions import (
+    AuthenticationError,
+    MisconfiguredError,
+    RequestError,
+    TimeOutError,
+)
 
 if TYPE_CHECKING:
     from eodag.config import PluginConfig
@@ -879,6 +884,8 @@ class QueryStringSearch(Search):
                     url, timeout=timeout, headers=USER_AGENT, **kwargs
                 )
                 response.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=timeout) from exc
         except (requests.RequestException, URLError) as err:
             err_msg = err.readlines() if hasattr(err, "readlines") else ""
             if exception_message:
@@ -954,6 +961,8 @@ class ODataV4Search(QueryStringSearch):
                         metadata_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT
                     )
                     response.raise_for_status()
+                except requests.exceptions.Timeout as exc:
+                    raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
                 except requests.RequestException:
                     logger.exception(
                         "Skipping error while searching for %s %s instance:",
@@ -1188,6 +1197,7 @@ class PostJsonSearch(QueryStringSearch):
         info_message: Optional[str] = None,
         exception_message: Optional[str] = None,
     ) -> Response:
+        timeout = getattr(self.config, "timeout", HTTP_REQ_TIMEOUT)
         try:
             # auth if needed
             kwargs = {}
@@ -1208,10 +1218,12 @@ class PostJsonSearch(QueryStringSearch):
                 url,
                 json=self.query_params,
                 headers=USER_AGENT,
-                timeout=getattr(self.config, "timeout", HTTP_REQ_TIMEOUT),
+                timeout=timeout,
                 **kwargs,
             )
             response.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=timeout) from exc
         except (requests.RequestException, URLError) as err:
             # check if error is identified as auth_error in provider conf
             auth_errors = getattr(self.config, "auth_error_code", [None])
diff --git a/eodag/rest/server.py b/eodag/rest/server.py
index 7d711148..2d344792 100755
--- a/eodag/rest/server.py
+++ b/eodag/rest/server.py
@@ -69,6 +69,7 @@ from eodag.utils.exceptions import (
     NoMatchingProductType,
     NotAvailableError,
     RequestError,
+    TimeOutError,
     UnsupportedProductType,
     UnsupportedProvider,
     ValidationError,
@@ -286,6 +287,19 @@ async def handle_server_error(request: Request, error: Exception) -> ORJSONRespo
     )
 
 
+@app.exception_handler(TimeOutError)
+async def handle_timeout(request: Request, error: Exception) -> ORJSONResponse:
+    """Timeout [504] errors handle"""
+    logger.error(f"{type(error).__name__}: {str(error)}")
+    return await default_exception_handler(
+        request,
+        HTTPException(
+            status_code=504,
+            detail=f"{type(error).__name__}: {str(error)}",
+        ),
+    )
+
+
 @router.get("/", tags=["Capabilities"])
 def catalogs_root(request: Request) -> Any:
     """STAC catalogs root"""
diff --git a/eodag/rest/utils.py b/eodag/rest/utils.py
index e4a7051c..f8b4ab12 100644
--- a/eodag/rest/utils.py
+++ b/eodag/rest/utils.py
@@ -499,6 +499,9 @@ def search_products(
         else:
             criterias.update(arguments)
 
+        if provider:
+            criterias["raise_errors"] = True
+
         # We remove potential None values to use the default values of the search method
         criterias = dict((k, v) for k, v in criterias.items() if v is not None)
 
@@ -565,6 +568,8 @@ def search_product_by_id(
     :raises: :class:`~eodag.utils.exceptions.ValidationError`
     :raises: RuntimeError
     """
+    if provider:
+        kwargs["raise_errors"] = True
     try:
         products, _ = eodag_api.search(
             id=uid, productType=product_type, provider=provider, **kwargs
@@ -676,7 +681,7 @@ def get_stac_item_by_id(
     _dc_qs = kwargs.get("_dc_qs", None)
 
     found_products = search_product_by_id(
-        item_id, product_type=product_type, _dc_qs=_dc_qs
+        item_id, product_type=product_type, provider=provider, _dc_qs=_dc_qs
     )
 
     if len(found_products) > 0:
diff --git a/eodag/utils/exceptions.py b/eodag/utils/exceptions.py
index dcfd788f..d12f18a9 100644
--- a/eodag/utils/exceptions.py
+++ b/eodag/utils/exceptions.py
@@ -20,7 +20,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from typing import Set, Tuple
+    from typing import Optional, Set, Tuple
 
 
 class ValidationError(Exception):
@@ -95,3 +95,17 @@ class NoMatchingProductType(Exception):
 
 class STACOpenerError(Exception):
     """An error indicating that a STAC file could not be opened"""
+
+
+class TimeOutError(RequestError):
+    """An error indicating that a timeout has occurred"""
+
+    def __init__(
+        self, exception: Optional[Exception] = None, timeout: Optional[float] = None
+    ) -> None:
+        url = getattr(getattr(exception, "request", None), "url", None)
+        timeout_msg = f"({timeout}s)" if timeout else ""
+        message = (
+            f"Request timeout {timeout_msg} for URL {url}" if url else str(exception)
+        )
+        super().__init__(message)


Test patch:
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index 36f51cc5..7cdd86f1 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -29,6 +29,7 @@ from fastapi.testclient import TestClient
 from shapely.geometry import box
 
 from eodag.utils import USER_AGENT, MockResponse
+from eodag.utils.exceptions import TimeOutError
 from tests import mock
 from tests.context import (
     DEFAULT_ITEMS_PER_PAGE,
@@ -453,7 +454,7 @@ class RequestTestCase(unittest.TestCase):
     @mock.patch(
         "eodag.rest.utils.eodag_api.search",
         autospec=True,
-        side_effect=AuthenticationError("you are no authorized"),
+        side_effect=AuthenticationError("you are not authorized"),
     )
     def test_auth_error(self, mock_search):
         """A request to eodag server raising a Authentication error must return a 500 HTTP error code"""
@@ -466,10 +467,29 @@ class RequestTestCase(unittest.TestCase):
 
             self.assertIn("description", response_content)
             self.assertIn("AuthenticationError", str(cm_logs.output))
-            self.assertIn("you are no authorized", str(cm_logs.output))
+            self.assertIn("you are not authorized", str(cm_logs.output))
 
         self.assertEqual(500, response.status_code)
 
+    @mock.patch(
+        "eodag.rest.utils.eodag_api.search",
+        autospec=True,
+        side_effect=TimeOutError("too long"),
+    )
+    def test_timeout_error(self, mock_search):
+        """A request to eodag server raising a Authentication error must return a 500 HTTP error code"""
+        with self.assertLogs(level="ERROR") as cm_logs:
+            response = self.app.get(
+                f"search?collections={self.tested_product_type}", follow_redirects=True
+            )
+            response_content = json.loads(response.content.decode("utf-8"))
+
+            self.assertIn("description", response_content)
+            self.assertIn("TimeOutError", str(cm_logs.output))
+            self.assertIn("too long", str(cm_logs.output))
+
+        self.assertEqual(504, response.status_code)
+
     def test_filter(self):
         """latestIntersect filter should only keep the latest products once search area is fully covered"""
         result1 = self._request_valid(
diff --git a/tests/units/test_search_plugins.py b/tests/units/test_search_plugins.py
index 9ee6a43a..3bf5dc85 100644
--- a/tests/units/test_search_plugins.py
+++ b/tests/units/test_search_plugins.py
@@ -24,11 +24,13 @@ from unittest import mock
 
 import boto3
 import dateutil
+import requests
 import responses
 import yaml
 from botocore.stub import Stubber
 from requests import RequestException
 
+from eodag.utils.exceptions import TimeOutError
 from tests.context import (
     DEFAULT_MISSION_START_DATE,
     HTTP_REQ_TIMEOUT,
@@ -529,6 +531,19 @@ class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
         )
         self.assertNotIn("bar", products[0].properties)
 
+    @mock.patch(
+        "eodag.plugins.search.qssearch.requests.get",
+        autospec=True,
+        side_effect=requests.exceptions.Timeout(),
+    )
+    def test_plugins_search_querystringseach_timeout(self, mock__request):
+        search_plugin = self.get_search_plugin(self.product_type, "peps")
+        with self.assertRaises(TimeOutError):
+            search_plugin.query(
+                productType="S1_SAR_SLC",
+                auth=None,
+            )
+
 
 class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
     def setUp(self):