diff --git a/.readthedocs.yml b/.readthedocs.yml index bafd26a..7fb92e4 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,4 +17,5 @@ python: - develop sphinx: + configuration: docs/sphinx/conf.py fail_on_warning: true diff --git a/elastic_transport/_exceptions.py b/elastic_transport/_exceptions.py index b1fc8b3..926ce1e 100644 --- a/elastic_transport/_exceptions.py +++ b/elastic_transport/_exceptions.py @@ -50,9 +50,6 @@ def __repr__(self) -> str: parts.append(f"errors={self.errors!r}") return "{}({})".format(self.__class__.__name__, ", ".join(parts)) - def __str__(self) -> str: - return str(self.message) - class SniffingError(TransportError): """Error that occurs during the sniffing of nodes""" @@ -67,29 +64,14 @@ class SerializationError(TransportError): class ConnectionError(TransportError): """Error raised by the HTTP connection""" - def __str__(self) -> str: - if self.errors: - return f"Connection error caused by: {self.errors[0].__class__.__name__}({self.errors[0]})" - return "Connection error" - class TlsError(ConnectionError): """Error raised by during the TLS handshake""" - def __str__(self) -> str: - if self.errors: - return f"TLS error caused by: {self.errors[0].__class__.__name__}({self.errors[0]})" - return "TLS error" - class ConnectionTimeout(TransportError): """Connection timed out during an operation""" - def __str__(self) -> str: - if self.errors: - return f"Connection timeout caused by: {self.errors[0].__class__.__name__}({self.errors[0]})" - return "Connection timed out" - class ApiError(Exception): """Base-class for clients that raise errors due to a response such as '404 Not Found'""" diff --git a/tests/async_/test_async_transport.py b/tests/async_/test_async_transport.py index 2e288e2..0ff3d55 100644 --- a/tests/async_/test_async_transport.py +++ b/tests/async_/test_async_transport.py @@ -45,6 +45,14 @@ from tests.conftest import AsyncDummyNode +def exception_to_dict(exc: TransportError) -> dict: + return { + "type": exc.__class__.__name__, + "message": exc.message, + "errors": [exception_to_dict(e) for e in exc.errors], + } + + @pytest.mark.asyncio async def test_async_transport_httpbin(httpbin_node_config): t = AsyncTransport([httpbin_node_config], meta_header=False) @@ -139,14 +147,39 @@ async def test_request_will_fail_after_x_retries(): ) ], node_class=AsyncDummyNode, + max_retries=0, ) with pytest.raises(ConnectionError) as e: await t.perform_request("GET", "/") + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + } + + # max_retries=2 + with pytest.raises(ConnectionError) as e: + await t.perform_request("GET", "/", max_retries=2) + assert 4 == len(t.node_pool.get().calls) - assert len(e.value.errors) == 3 - assert all(isinstance(error, ConnectionError) for error in e.value.errors) + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [ + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + ], + } @pytest.mark.parametrize("retry_on_timeout", [True, False]) @@ -174,15 +207,30 @@ async def test_retry_on_timeout(retry_on_timeout): ) if retry_on_timeout: - with pytest.raises(ConnectionError) as e: + with pytest.raises(TransportError) as e: await t.perform_request("GET", "/") - assert len(e.value.errors) == 1 - assert isinstance(e.value.errors[0], ConnectionTimeout) + + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "error!", + "errors": [ + { + "type": "ConnectionTimeout", + "message": "abandon ship", + "errors": [], + } + ], + } else: - with pytest.raises(ConnectionTimeout) as e: + with pytest.raises(TransportError) as e: await t.perform_request("GET", "/") - assert len(e.value.errors) == 0 + + assert exception_to_dict(e.value) == { + "type": "ConnectionTimeout", + "message": "abandon ship", + "errors": [], + } @pytest.mark.asyncio @@ -254,8 +302,27 @@ async def test_failed_connection_will_be_marked_as_dead(): await t.perform_request("GET", "/") assert 0 == len(t.node_pool._alive_nodes) assert 2 == len(t.node_pool._dead_nodes.queue) - assert len(e.value.errors) == 3 - assert all(isinstance(error, ConnectionError) for error in e.value.errors) + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [ + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + ], + } @pytest.mark.asyncio @@ -602,7 +669,12 @@ def sniff_error(*_): with pytest.raises(TransportError) as e: await t.perform_request("GET", "/") - assert str(e.value) == "This is an error!" + + assert exception_to_dict(e.value) == { + "type": "TransportError", + "message": "This is an error!", + "errors": [], + } assert t._last_sniffed_at == last_sniffed_at assert t._sniffing_task.done() @@ -628,9 +700,11 @@ async def test_sniff_on_start_no_results_errors(sniff_callback): with pytest.raises(SniffingError) as e: await t._async_call() - assert ( - str(e.value) == "No viable nodes were discovered on the initial sniff attempt" - ) + assert exception_to_dict(e.value) == { + "type": "SniffingError", + "message": "No viable nodes were discovered on the initial sniff attempt", + "errors": [], + } @pytest.mark.parametrize("pool_size", [1, 8]) diff --git a/tests/conftest.py b/tests/conftest.py index cec6eb4..0f7f032 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import copy import hashlib import logging import socket @@ -40,7 +41,8 @@ def __init__(self, config: NodeConfig): def perform_request(self, *args, **kwargs): self.calls.append((args, kwargs)) if self.exception: - raise self.exception + # Raising the same exception can cause recursion errors when exceptions are linked together + raise copy.deepcopy(self.exception) meta = ApiResponseMeta( node=self.config, duration=0.0, @@ -55,7 +57,8 @@ class AsyncDummyNode(DummyNode): async def perform_request(self, *args, **kwargs): self.calls.append((args, kwargs)) if self.exception: - raise self.exception + # Raising the same exception can cause recursion errors when exceptions are linked together + raise copy.deepcopy(self.exception) meta = ApiResponseMeta( node=self.config, duration=0.0, diff --git a/tests/test_transport.py b/tests/test_transport.py index 07d063d..8c9cca4 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -42,6 +42,14 @@ from tests.conftest import DummyNode +def exception_to_dict(exc: TransportError) -> dict: + return { + "type": exc.__class__.__name__, + "message": exc.message, + "errors": [exception_to_dict(e) for e in exc.errors], + } + + def test_transport_close_node_pool(): t = Transport([NodeConfig("http", "localhost", 443)]) with mock.patch.object(t.node_pool.all()[0], "close") as node_close: @@ -138,37 +146,33 @@ def test_request_will_fail_after_x_retries(): with pytest.raises(ConnectionError) as e: t.perform_request("GET", "/") - assert 1 == len(t.node_pool.get().calls) - assert len(e.value.errors) == 0 - - # max_retries=3 - t = Transport( - [ - NodeConfig( - "http", - "localhost", - 80, - _extras={"exception": ConnectionError("abandon ship")}, - ) - ], - node_class=DummyNode, - max_retries=3, - ) - - with pytest.raises(ConnectionError) as e: - t.perform_request("GET", "/") - - assert 4 == len(t.node_pool.get().calls) - assert len(e.value.errors) == 3 - assert all(isinstance(error, ConnectionError) for error in e.value.errors) + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + } # max_retries=2 in perform_request() with pytest.raises(ConnectionError) as e: t.perform_request("GET", "/", max_retries=2) - assert 7 == len(t.node_pool.get().calls) - assert len(e.value.errors) == 2 - assert all(isinstance(error, ConnectionError) for error in e.value.errors) + assert 4 == len(t.node_pool.get().calls) + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [ + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + ], + } @pytest.mark.parametrize("retry_on_timeout", [True, False]) @@ -197,13 +201,28 @@ def test_retry_on_timeout(retry_on_timeout): if retry_on_timeout: with pytest.raises(ConnectionError) as e: t.perform_request("GET", "/") - assert len(e.value.errors) == 1 - assert isinstance(e.value.errors[0], ConnectionTimeout) + + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "error!", + "errors": [ + { + "type": "ConnectionTimeout", + "message": "abandon ship", + "errors": [], + } + ], + } else: with pytest.raises(ConnectionTimeout) as e: t.perform_request("GET", "/") - assert len(e.value.errors) == 0 + + assert exception_to_dict(e.value) == { + "type": "ConnectionTimeout", + "message": "abandon ship", + "errors": [], + } def test_retry_on_status(): @@ -273,8 +292,27 @@ def test_failed_connection_will_be_marked_as_dead(): t.perform_request("GET", "/") assert 0 == len(t.node_pool._alive_nodes) assert 2 == len(t.node_pool._dead_nodes.queue) - assert len(e.value.errors) == 3 - assert all(isinstance(error, ConnectionError) for error in e.value.errors) + assert exception_to_dict(e.value) == { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [ + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + { + "type": "ConnectionError", + "message": "abandon ship", + "errors": [], + }, + ], + } def test_resurrected_connection_will_be_marked_as_live_on_success(): @@ -603,7 +641,12 @@ def sniff_error(*_): with pytest.raises(TransportError) as e: t.perform_request("GET", "/") - assert str(e.value) == "This is an error!" + + assert exception_to_dict(e.value) == { + "type": "TransportError", + "message": "This is an error!", + "errors": [], + } assert t._last_sniffed_at == last_sniffed_at assert t._sniffing_lock.locked() is False @@ -620,9 +663,11 @@ def test_sniff_on_start_no_results_errors(): sniff_callback=lambda *_: [], ) - assert ( - str(e.value) == "No viable nodes were discovered on the initial sniff attempt" - ) + assert exception_to_dict(e.value) == { + "type": "SniffingError", + "message": "No viable nodes were discovered on the initial sniff attempt", + "errors": [], + } @pytest.mark.parametrize("pool_size", [1, 8])