From c698c1dc80b2cf4bab4aa631dcdbc5ffe18796c7 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Fri, 17 Oct 2025 17:41:50 +0300 Subject: [PATCH] Adding new ExternalAuthProviderError that will be raised when we receive 'problem with LDAP service' response from server. --- redis/_parsers/base.py | 6 ++ redis/exceptions.py | 8 ++ tests/conftest.py | 9 ++ tests/test_parsers/test_errors.py | 167 ++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 tests/test_parsers/test_errors.py diff --git a/redis/_parsers/base.py b/redis/_parsers/base.py index 4c83495a61..63bf5d7795 100644 --- a/redis/_parsers/base.py +++ b/redis/_parsers/base.py @@ -27,6 +27,7 @@ ClusterDownError, ConnectionError, ExecAbortError, + ExternalAuthProviderError, MasterDownError, ModuleError, MovedError, @@ -60,6 +61,10 @@ "Client sent AUTH, but no password is set": AuthenticationError, } +EXTERNAL_AUTH_PROVIDER_ERROR = { + "problem with LDAP service": ExternalAuthProviderError, +} + logger = logging.getLogger(__name__) @@ -81,6 +86,7 @@ class BaseParser(ABC): NO_SUCH_MODULE_ERROR: ModuleError, MODULE_UNLOAD_NOT_POSSIBLE_ERROR: ModuleError, **NO_AUTH_SET_ERROR, + **EXTERNAL_AUTH_PROVIDER_ERROR, }, "OOM": OutOfMemoryError, "WRONGPASS": AuthenticationError, diff --git a/redis/exceptions.py b/redis/exceptions.py index 643444986b..1e21265524 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -245,3 +245,11 @@ class InvalidPipelineStack(RedisClusterException): """ pass + + +class ExternalAuthProviderError(ConnectionError): + """ + Raised when an external authentication provider returns an error. + """ + + pass diff --git a/tests/conftest.py b/tests/conftest.py index af2681732b..9d2f51795a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -334,6 +334,15 @@ def skip_if_resp_version(resp_version) -> _TestDecorator: return pytest.mark.skipif(check, reason=f"RESP version required != {resp_version}") +def skip_if_hiredis_parser() -> _TestDecorator: + try: + import hiredis # noqa + + return pytest.mark.skipif(True, reason="hiredis dependency found") + except ImportError: + return pytest.mark.skipif(False, reason="No hiredis dependency") + + def _get_client( cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs ): diff --git a/tests/test_parsers/test_errors.py b/tests/test_parsers/test_errors.py new file mode 100644 index 0000000000..8081e6481b --- /dev/null +++ b/tests/test_parsers/test_errors.py @@ -0,0 +1,167 @@ +import socket +from unittest.mock import patch + +import pytest +from redis.client import Redis +from redis.exceptions import ExternalAuthProviderError +from tests.conftest import skip_if_hiredis_parser + + +class MockSocket: + """Mock socket that simulates Redis protocol responses.""" + + def __init__(self): + self.sent_data = [] + self.closed = False + self.pending_responses = [] + + def connect(self, address): + pass + + def send(self, data): + """Simulate sending data to Redis.""" + if self.closed: + raise ConnectionError("Socket is closed") + self.sent_data.append(data) + + # Analyze the command and prepare appropriate response + if b"HELLO" in data: + response = b"%7\r\n$6\r\nserver\r\n$5\r\nredis\r\n$7\r\nversion\r\n$5\r\n7.4.0\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n" + self.pending_responses.append(response) + elif b"SET" in data: + response = b"+OK\r\n" + self.pending_responses.append(response) + elif b"GET" in data: + # Extract key and provide appropriate response + if b"hello" in data: + response = b"$5\r\nworld\r\n" + self.pending_responses.append(response) + # Handle specific keys used in tests + elif b"ldap_error" in data: + self.pending_responses.append(b"-ERR problem with LDAP service\r\n") + else: + self.pending_responses.append(b"$-1\r\n") # NULL response + else: + self.pending_responses.append(b"+OK\r\n") # Default response + + return len(data) + + def sendall(self, data): + """Simulate sending all data to Redis.""" + return self.send(data) + + def recv(self, bufsize): + """Simulate receiving data from Redis.""" + if self.closed: + raise ConnectionError("Socket is closed") + + # Use pending responses that were prepared when commands were sent + if self.pending_responses: + response = self.pending_responses.pop(0) + return response[:bufsize] # Respect buffer size + else: + # No data available - this should block or raise an exception + # For can_read checks, we should indicate no data is available + import errno + + raise BlockingIOError(errno.EAGAIN, "Resource temporarily unavailable") + + def recv_into(self, buffer, nbytes=0): + """ + Receive data from Redis and write it into the provided buffer. + Returns the number of bytes written. + + This method is used by the hiredis parser for efficient data reading. + """ + if self.closed: + raise ConnectionError("Socket is closed") + + # Use pending responses that were prepared when commands were sent + if self.pending_responses: + response = self.pending_responses.pop(0) + + # Determine how many bytes to write + if nbytes == 0: + nbytes = len(buffer) + + # Write data into the buffer (up to nbytes or response length) + bytes_to_write = min(len(response), nbytes, len(buffer)) + buffer[:bytes_to_write] = response[:bytes_to_write] + + return bytes_to_write + else: + # No data available - this should block or raise an exception + # For can_read checks, we should indicate no data is available + import errno + + raise BlockingIOError(errno.EAGAIN, "Resource temporarily unavailable") + + def fileno(self): + """Return a fake file descriptor for select/poll operations.""" + return 1 # Fake file descriptor + + def close(self): + """Simulate closing the socket.""" + self.closed = True + self.address = None + self.timeout = None + + def settimeout(self, timeout): + pass + + def setsockopt(self, level, optname, value): + pass + + def setblocking(self, blocking): + pass + + def shutdown(self, how): + pass + + +class TestErrorParsing: + def setup_method(self): + """Set up test fixtures with mocked sockets.""" + self.mock_sockets = [] + self.original_socket = socket.socket + + # Mock socket creation to return our mock sockets + def mock_socket_factory(*args, **kwargs): + mock_sock = MockSocket() + self.mock_sockets.append(mock_sock) + return mock_sock + + self.socket_patcher = patch("socket.socket", side_effect=mock_socket_factory) + self.socket_patcher.start() + + # Mock select.select to simulate data availability for reading + def mock_select(rlist, wlist, xlist, timeout=0): + # Check if any of the sockets in rlist have data available + ready_sockets = [] + for sock in rlist: + if hasattr(sock, "connected") and sock.connected and not sock.closed: + # Only return socket as ready if it actually has data to read + if hasattr(sock, "pending_responses") and sock.pending_responses: + ready_sockets.append(sock) + # Don't return socket as ready just because it received commands + # Only when there are actual responses available + return (ready_sockets, [], []) + + self.select_patcher = patch("select.select", side_effect=mock_select) + self.select_patcher.start() + + def teardown_method(self): + """Clean up test fixtures.""" + self.socket_patcher.stop() + self.select_patcher.stop() + + @skip_if_hiredis_parser() + @pytest.mark.parametrize("protocol_version", [2, 3]) + def test_external_auth_provider_error(self, protocol_version): + client = Redis( + protocol=protocol_version, + ) + client.set("hello", "world") + + with pytest.raises(ExternalAuthProviderError): + client.get("ldap_error")