Repository: CS-SI/eodag

Issue:
Include error from backend service when searching with invalid params
When making an invalid search request, we get an exception that isn't as descriptive as it should. For example, consider this code:
```py
from eodag import EODataAccessGateway, setup_logging
setup_logging(3)

dag = EODataAccessGateway()

dag.set_preferred_provider("dedl")

search_criteria = {
        "productType": "CAMS_EAC4",
        "start": "2024-09-01",
        "end": "2024-08-01",  # *** BEFORE START DATE ***
        "geom": {"lonmin": 1, "latmin": 43, "lonmax": 2, "latmax": 44},
        "count": True,
        "provider": "dedl",
        "raise_errors": True,
}

dag.search(**search_criteria)
```
The exception we get is:
```
eodag.utils.exceptions.RequestError: ('Skipping error while searching for dedl StacSearch instance', 
'400 Client Error: Bad Request for url: https://hda.data.destination-earth.eu/stac/search')
```
But since in this case we have enabled verbose logging, we can also see this in the console outpout:
```
2024-10-11 10:28:34,207 eodag.search.qssearch            [DEBUG   ] b'{"description":"1 error(s). datetime: Value error, Invalid datetime range, must match format (begin_date, end_date)"}'
```
If the user hasn't enabled verbose logging, they won't see this message and even with logging enabled they might miss it in the flow of messages. It would be better to include it in the exception message. As a matter of fact, some logic is present in the code to do precisely that, but it is not working as intended because it does an `if` statement to check if a variable points to `None` or a `Response` object. But a `Response` object evaluate to `False` in boolean context if it doesn't have a status code 200, so the condition isn't satisfied when we have a `Response` with an error status code and the error message included in the response coming from the provider server is not included.

Hints from issue comments:

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):
diff --git a/eodag/utils/exceptions.py b/eodag/utils/exceptions.py
index 02aa647c6..227597c3e 100644
--- a/eodag/utils/exceptions.py
+++ b/eodag/utils/exceptions.py
@@ -94,7 +94,10 @@ def from_error(cls, error: Exception, msg: Optional[str] = None):
         status_code = getattr(error, "code", None)
         text = getattr(error, "msg", None)
 
-        if response := getattr(error, "response", None):
+        response = getattr(error, "response", None)
+        # Explicitly test for None because response objects are considered false if they
+        # have a status code other than 200
+        if response is not None:
             status_code = response.status_code
             text = response.text
 


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)
diff --git a/tests/units/test_utils_exceptions.py b/tests/units/test_utils_exceptions.py
new file mode 100644
index 000000000..c82ea7fb5
--- /dev/null
+++ b/tests/units/test_utils_exceptions.py
@@ -0,0 +1,26 @@
+from unittest import TestCase
+
+from requests.models import Response
+
+from eodag.utils.exceptions import RequestError
+
+
+class TestRequestError(TestCase):
+    def test_request_error_includes_response_text(self):
+        """
+        This test triggers a bug in RequestError due to the code doing an if on a response
+        object without realizing that a response object evaluates to False if it doesn't
+        have a status code 200. This resulted in error text from the provider not being
+        included in the error.
+        """
+        original_exception = Exception()
+        response = Response()
+        response.status_code = 400
+        response._content = b"*** my response text ***"
+        original_exception.response = response
+
+        error = RequestError.from_error(original_exception)
+
+        # Verify that the error message from the server is included in the error
+        self.assertIn("*** my response text ***", str(error))
+        self.assertEqual(error.status_code, 400)