Repository: CS-SI/eodag

Issue:
Raise an error when a request to a backend provider fails
When a request to a provider such as cop_ads fails because of a malformed request (e.g. providing a date range with an end date before the start date), eodag logs the error, which may be unnotified by the user. Instead it should raise an error.

Discussion:

Solution patch:
diff --git a/docs/plugins_reference/auth.rst b/docs/plugins_reference/auth.rst
index 8f4b02064..fbad669f1 100644
--- a/docs/plugins_reference/auth.rst
+++ b/docs/plugins_reference/auth.rst
@@ -9,6 +9,8 @@ Multiple authentication plugins can be defined per provider under ``auth``, ``se
 using *matching settings* :attr:`~eodag.config.PluginConfig.matching_url` and
 :attr:`~eodag.config.PluginConfig.matching_conf` as configuration parameters.
 Credentials are automatically shared between plugins having the same *matching settings*.
+Authentication plugins without *matching settings* configured will not be shared and will automatically match their
+provider.
 
 Authentication plugins must inherit the following class and implement :meth:`authenticate`:
 
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index 83926fba7..2302c53b9 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -79,6 +79,7 @@
     DownloadError,
     MisconfiguredError,
     NotAvailableError,
+    RequestError,
     TimeOutError,
 )
 
@@ -193,18 +194,11 @@ def order_download(
                     logger.debug(ordered_message)
                     product.properties["storageStatus"] = STAGING_STATUS
                 except RequestException as e:
-                    if hasattr(e, "response") and (
-                        content := getattr(e.response, "content", None)
-                    ):
-                        error_message = f"{content.decode('utf-8')} - {e}"
-                    else:
-                        error_message = str(e)
-                    logger.warning(
-                        "%s could not be ordered, request returned %s",
-                        product.properties["title"],
-                        error_message,
-                    )
                     self._check_auth_exception(e)
+                    title = product.properties["title"]
+                    message = f"{title} could not be ordered"
+                    raise RequestError.from_error(e, message) from e
+
                 return self.order_response_process(response, product)
         except requests.exceptions.Timeout as exc:
             raise TimeOutError(exc, timeout=timeout) from exc
diff --git a/eodag/plugins/manager.py b/eodag/plugins/manager.py
index 32335d877..671868c7a 100644
--- a/eodag/plugins/manager.py
+++ b/eodag/plugins/manager.py
@@ -335,12 +335,8 @@ def _is_auth_plugin_matching(
                 ):
                     # url matches
                     return True
-            if not plugin_matching_conf and not plugin_matching_url:
-                # plugin without match criteria
-                return True
-            else:
-                # no match
-                return False
+            # no match
+            return False
 
         # providers configs with given provider at first
         sorted_providers_config = deepcopy(self.providers_config)
@@ -354,7 +350,20 @@ def _is_auth_plugin_matching(
                 auth_conf = getattr(provider_conf, key, None)
                 if auth_conf is None:
                     continue
-                if _is_auth_plugin_matching(auth_conf, matching_url, matching_conf):
+                # plugin without configured match criteria: only works for given provider
+                unconfigured_match = (
+                    True
+                    if (
+                        not getattr(auth_conf, "matching_conf", {})
+                        and not getattr(auth_conf, "matching_url", None)
+                        and provider == plugin_provider
+                    )
+                    else False
+                )
+
+                if unconfigured_match or _is_auth_plugin_matching(
+                    auth_conf, matching_url, matching_conf
+                ):
                     auth_conf.priority = provider_conf.priority
                     plugin = cast(
                         Authentication,
diff --git a/eodag/resources/product_types.yml b/eodag/resources/product_types.yml
index b54de9763..08bb74280 100644
--- a/eodag/resources/product_types.yml
+++ b/eodag/resources/product_types.yml
@@ -2866,7 +2866,7 @@ ERA5_LAND:
   sensorType: ATMOSPHERIC
   license: proprietary
   title: ERA5-Land hourly data from 1950 to present
-  missionStartDate: "1950-01-01T00:00:00Z"
+  missionStartDate: "1950-01-01T01:00:00Z"
 
 ERA5_LAND_MONTHLY:
   abstract: |
diff --git a/eodag/resources/providers.yml b/eodag/resources/providers.yml
index 097ff545d..849094cc0 100644
--- a/eodag/resources/providers.yml
+++ b/eodag/resources/providers.yml
@@ -1567,7 +1567,6 @@
       Content-Type: application/json
   auth: !plugin
     type: GenericAuth
-    matching_url: https://catalogue.onda-dias.eu
 
 ---
 !provider # MARK: astraea_eod
@@ -4189,36 +4188,12 @@
       variable:
         - '{{"variable": "{variable}"}}'
         - '$.null'
-      leadtime_hour:
-        - '{{"leadtime_hour": {leadtime_hour}}}'
-        - '$.null'
-      leadtime_month:
-        - '{{"leadtime_month": {leadtime_month}}}'
-        - '$.null'
-      origin:
-        - '{{"origin": "{origin}"}}'
-        - '$.null'
       system:
         - '{{"system": "{system}"}}'
         - '$.null'
-      format:
-        - '{{"format": "{format}"}}'
-        - '$.null'
-      pressure_level:
-        - '{{"pressure_level": {pressure_level}}}'
-        - '$.null'
-      model_level:
-        - '{{"model_level": {model_level}}}'
-        - '$.null'
-      sensor_and_algorithm:
-        - '{{"sensor_and_algorithm": "{sensor_and_algorithm}"}}'
-        - '$.null'
       version:
         - '{{"version": {version}}}'
         - '$.null'
-      time:
-        - '{{"time": {time}}}'
-        - '$.null'
       region:
         - '{{"region": {region}}}'
         - '$.null'
@@ -4228,72 +4203,18 @@
       source:
         - '{{"source": {source}}}'
         - '$.null'
-      quantity:
-        - '{{"quantity": "{quantity}"}}'
-        - '$.null'
-      input_observations:
-        - '{{"input_observations": "{input_observations}"}}'
-        - '$.null'
-      aggregation:
-        - '{{"aggregation": "{aggregation}"}}'
-        - '$.null'
       model:
         - '{{"model": {model}}}'
         - '$.null'
       level:
         - '{{"level": {level}}}'
         - '$.null'
-      forcing_type:
-        - '{{"forcing_type": "{forcing_type}"}}'
-        - '$.null'
-      sky_type:
-        - '{{"sky_type": {sky_type}}}'
-        - '$.null'
-      band:
-        - '{{"band": {band}}}'
-        - '$.null'
-      aerosol_type:
-        - '{{"aerosol_type": {aerosol_type}}}'
-        - '$.null'
       step:
         - '{{"step": {step}}}'
         - '$.null'
-      longitude:
-        - '{{"longitude": "{longitude}"}}'
-        - '$.null'
-      latitude:
-        - '{{"latitude": "{latitude}"}}'
-        - '$.null'
-      altitude:
-        - '{{"altitude": "{altitude}"}}'
-        - '$.null'
-      time_reference:
-        - '{{"time_reference": "{time_reference}"}}'
-        - '$.null'
-      grid:
-        - '{{"grid": "{grid}"}}'
-        - '$.null'
-      soil_level:
-        - '{{"soil_level": {soil_level}}}'
-        - '$.null'
-      year:
-        - '{{"year": {year}}}'
-        - '$.null'
-      month:
-        - '{{"month": {month}}}'
-        - '$.null'
-      day:
-        - '{{"day": {day}}}'
-        - '$.null'
       satellite:
         - '{{"satellite": {satellite}}}'
         - '$.null'
-      cdr_type:
-        - '{{"cdr_type": "{cdr_type}"}}'
-        - '$.null'
-      statistic:
-        - '{{"statistic": {statistic}}}'
-        - '$.null'
       sensor:
         - '{{"sensor": "{sensor}"}}'
         - '$.null'
@@ -4785,12 +4706,6 @@
       providerProductType:
         - '{{"productType": "{providerProductType}"}}'
         - '$.null'
-      timeliness:
-        - '{{"timeliness": "{timeliness}"}}'
-        - '$.null'
-      orbitDirection:
-        - '{{"orbitDirection": "{orbitDirection}"}}'
-        - '$.null'
       variable:
         - '{{"variable": {variable}}}'
         - '$.null'
@@ -5397,6 +5312,9 @@
       format: grib
       metadata_mapping:
         id: '$.id'
+        month:
+          - '{{"month": "{month}"}}'
+          - '$.null'
         startTimeFromAscendingNode:
           - |
             {{
diff --git a/eodag/rest/errors.py b/eodag/rest/errors.py
index 6ff067f46..574a3e376 100644
--- a/eodag/rest/errors.py
+++ b/eodag/rest/errors.py
@@ -126,7 +126,10 @@ async def eodag_errors_handler(request: Request, exc: Exception) -> ORJSONRespon
     if not isinstance(exc, EodagError):
         return starlette_exception_handler(request, exc)
 
-    code = EODAG_DEFAULT_STATUS_CODES.get(type(exc), getattr(exc, "status_code", 500))
+    exception_status_code = getattr(exc, "status_code", None)
+    default_status_code = exception_status_code or 500
+    code = EODAG_DEFAULT_STATUS_CODES.get(type(exc), default_status_code)
+
     detail = f"{type(exc).__name__}: {str(exc)}"
 
     if type(exc) in (MisconfiguredError, AuthenticationError, TimeOutError):


Test patch:
diff --git a/tests/integration/test_core_search.py b/tests/integration/test_core_search.py
index e5ae64b2d..46b838448 100644
--- a/tests/integration/test_core_search.py
+++ b/tests/integration/test_core_search.py
@@ -495,6 +495,23 @@ def test_core_search_auths_matching(
             },
         )
 
+        # auth plugin without match configuration
+        self.dag.add_provider(
+            "provider_without_match_configured",
+            "https://foo.bar/baz/search",
+            search={"need_auth": True},
+            auth={
+                "type": "GenericAuth",
+                "credentials": {
+                    "username": "some-username",
+                    "password": "some-password",
+                },
+            },
+        )
+        self.dag.search(provider="provider_without_match_configured")
+        self.assertEqual(mock_query.call_args[0][1].auth.username, "some-username")
+        mock_query.reset_mock()
+
         # search endpoint matching
         self.dag.add_provider(
             "provider_matching_search_api",
diff --git a/tests/units/test_download_plugins.py b/tests/units/test_download_plugins.py
index cda5f43c6..d51f3454d 100644
--- a/tests/units/test_download_plugins.py
+++ b/tests/units/test_download_plugins.py
@@ -28,12 +28,14 @@
 from typing import Any, Callable, Dict
 from unittest import mock
 
+import pytest
 import responses
 import yaml
+from requests import RequestException
 
 from eodag.api.product.metadata_mapping import DEFAULT_METADATA_MAPPING
 from eodag.utils import ProgressCallback
-from eodag.utils.exceptions import DownloadError, NoMatchingProductType
+from eodag.utils.exceptions import DownloadError, NoMatchingProductType, RequestError
 from tests import TEST_RESOURCES_PATH
 from tests.context import (
     DEFAULT_STREAM_REQUESTS_TIMEOUT,
@@ -1157,6 +1159,38 @@ def test_plugins_download_http_order_get(self, mock_request):
             else:
                 del plugin.config.timeout
 
+    @mock.patch("eodag.plugins.download.http.requests.request", autospec=True)
+    def test_plugins_download_http_order_get_raises_if_request_failed(
+        self, mock_request
+    ):
+        """HTTPDownload.order_download() must raise an error if request to backend
+        provider failed"""
+
+        # Configure mock to raise an error
+        mock_response = mock_request.return_value.__enter__.return_value
+        mock_response.raise_for_status.side_effect = RequestException("Some error msg")
+
+        plugin = self.get_download_plugin(self.product)
+        self.product.properties["downloadLink"] = "https://peps.cnes.fr/dummy"
+        self.product.properties["orderLink"] = "http://somewhere/order"
+
+        auth_plugin = self.get_auth_plugin(plugin, self.product)
+        auth_plugin.config.credentials = {"username": "foo", "password": "bar"}
+        auth = auth_plugin.authenticate()
+
+        # Verify that a RequestError is raised
+        with pytest.raises(RequestError):
+            plugin.order_download(self.product, auth=auth)
+
+        mock_request.assert_called_once_with(
+            method="GET",
+            url=self.product.properties["orderLink"],
+            auth=auth,
+            headers=USER_AGENT,
+            timeout=5,
+            verify=True,
+        )
+
     @mock.patch("eodag.plugins.download.http.requests.request", autospec=True)
     def test_plugins_download_http_order_post(self, mock_request):
         """HTTPDownload.order_download() must request using orderLink and POST protocol"""
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index 0e16f846b..863578eb9 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -323,6 +323,7 @@ def _request_valid_raw(
         post_data: Optional[Any] = None,
         search_call_count: Optional[int] = None,
         search_result: SearchResult = None,
+        expected_status_code: int = 200,
     ) -> httpx.Response:
         if search_result:
             mock_search.return_value = search_result
@@ -354,7 +355,7 @@ def _request_valid_raw(
         elif expected_search_kwargs is not None:
             mock_search.assert_called_once_with(**expected_search_kwargs)
 
-        self.assertEqual(200, response.status_code, response.text)
+        self.assertEqual(expected_status_code, response.status_code, response.text)
 
         return response
 
@@ -1150,6 +1151,34 @@ def test_download_item_from_collection_no_stream(
             expected_file
         ), f"File {expected_file} should have been deleted"
 
+    @mock.patch(
+        "eodag.plugins.authentication.base.Authentication.authenticate",
+        autospec=True,
+    )
+    @mock.patch(
+        "eodag.plugins.download.base.Download._stream_download_dict",
+        autospec=True,
+    )
+    def test_error_handler_supports_request_exception_with_status_code_none(
+        self, mock_stream_download: Mock, mock_auth: Mock
+    ):
+        """
+        A RequestError with a status code set to None (the default value) should not
+        crash the server. This test ensures that it doesn't crash the server and that a
+        500 status code is returned to the user.
+        """
+        # Make _stream_download_dict reaise an exception object with a status_code
+        # attribute set to None
+        exception = RequestError()
+        exception.status_code = None
+        mock_stream_download.side_effect = exception
+
+        response = self._request_valid_raw(
+            f"collections/{self.tested_product_type}/items/foo/download?provider=peps",
+            expected_status_code=500,
+        )
+        self.assertIn("RequestError", response.text)
+
     def test_root(self):
         """Request to / should return a valid response"""
         resp_json = self._request_valid("", check_links=False)