Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions redis/_parsers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ClusterDownError,
ConnectionError,
ExecAbortError,
ExternalAuthProviderError,
MasterDownError,
ModuleError,
MovedError,
Expand Down Expand Up @@ -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__)


Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions redis/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,11 @@ class InvalidPipelineStack(RedisClusterException):
"""

pass


class ExternalAuthProviderError(ConnectionError):
"""
Raised when an external authentication provider returns an error.
"""

pass
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
167 changes: 167 additions & 0 deletions tests/test_parsers/test_errors.py
Original file line number Diff line number Diff line change
@@ -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,
Copy link

Copilot AI Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing decode_responses parameter initialization. Consider explicitly setting this to False or True to make the test's expectations clear and avoid relying on default behavior.

Suggested change
protocol=protocol_version,
protocol=protocol_version,
decode_responses=False,

Copilot uses AI. Check for mistakes.

)
client.set("hello", "world")

with pytest.raises(ExternalAuthProviderError):
client.get("ldap_error")