Skip to content

Commit 3742d1f

Browse files
committed
RDBC-948: Ensure topology handling matches C# Client.
Handles network errors during connection failover more reliably by catching both requests-level and OS-level socket exceptions. This mirrors the behavior of the C# client and prevents connection failures due to DNS resolution issues or server unavailability. Also, refactors the failover logic to improve retry behavior on failed requests, ensuring that the next preferred node is chosen and retried, while avoiding infinite loops by checking for already failed nodes. Notifies listeners about failed requests with full details, and broadcasts commands where applicable to improve efficiency. Finally, ensures that only member nodes without failures are chosen first. Adds tests to verify failover scenarios with invalid DNS configurations and unreachable endpoints.
1 parent 2161369 commit 3742d1f

3 files changed

Lines changed: 135 additions & 12 deletions

File tree

ravendb/http/request_executor.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,13 @@ def _send_request_to_server(
633633
self._throw_failed_to_contact_all_nodes(command, request)
634634

635635
return None
636-
except IOError as e:
636+
except (requests.RequestException, OSError) as e:
637+
# RDBC-948: https://issues.hibernatingrhinos.com/issue/RDBC-948/Python-client-connection-failover-breaks-with-unknown-DNS-name-or-server-is-down.
638+
# Handle failover on network errors from both requests and the OS:
639+
# - RequestException covers requests' network stack (connect, TLS, proxies, etc.).
640+
# - OSError covers socket-level issues like DNS getaddrinfo on some platforms.
641+
# Different OS/resolvers surface the same fault differently; catching both mirrors the C# client
642+
# (HttpRequestException/SocketException) and makes failover reliable.
637643
if not should_retry:
638644
raise
639645

@@ -805,7 +811,8 @@ def _throw_failed_to_contact_all_nodes(self, command: RavenCommand, request: req
805811
)
806812

807813
if len(command.failed_nodes) == 1:
808-
raise command.failed_nodes.popitem()
814+
# raise the single recorded exception
815+
raise next(iter(command.failed_nodes.values()))
809816

810817
message = (
811818
f"Tried to send {command._result_class.__name__} request via {request.method}"
@@ -1159,39 +1166,45 @@ def __handle_server_down(
11591166
if command.failed_nodes is None:
11601167
command.failed_nodes = {}
11611168

1162-
return (
1163-
False # todo: command.failed_nodes[chosen_node] = self.__read_exception_from_server(request, response, e)
1164-
)
1169+
# record the failure for this node
1170+
if not command.is_failed_with_node(chosen_node):
1171+
command.failed_nodes[chosen_node] = self.__read_exception_from_server(request, response, e)
11651172

1173+
# If the node is not part of the topology, we can't failover using selector.
11661174
if node_index is None:
1167-
# We executed request over a node not in the topology. This means no failover...
11681175
return False
11691176

1177+
# If we don't have a selector yet, we also cannot failover.
11701178
if self._node_selector is None:
1171-
# todo: spawnHealthChecks(chosenNode, nodeIndex)
11721179
return False
11731180

1174-
# As the server is down, we discard the server version to ensure we update when it goes up.
1181+
# As the server is down, discard server version to ensure it updates when back up.
11751182
chosen_node.discard_server_version()
11761183

1184+
# Mark the node as failed to move selection forward
11771185
self._node_selector.on_failed_request(node_index)
11781186

1187+
# For broadcastable commands, attempt to broadcast instead of single-node retry.
11791188
if self.should_broadcast(command):
11801189
command.result = self.__broadcast(command, session_info)
11811190
return True
11821191

1183-
# todo: self.spawn_health_checks(chosen_node, node_index)
1184-
1192+
# Choose the next preferred node and retry
11851193
index_node_and_etag = self._node_selector.get_preferred_node_with_topology()
1194+
1195+
# If topology changed since we started, clear failed nodes record to allow retries
11861196
if command.failover_topology_etag != self.topology_etag:
11871197
command.failed_nodes.clear()
11881198
command.failover_topology_etag = self.topology_etag
11891199

1200+
# Avoid infinite loop if the next node is already marked as failed
11901201
if index_node_and_etag.current_node in command.failed_nodes:
11911202
return False
11921203

1193-
self.__on_failed_request_invoke(url, e, request, response)
1204+
# Notify listeners about the failed request with full details
1205+
self.__on_failed_request_invoke_details(url, e, request, response)
11941206

1207+
# Retry the command on the next node
11951208
self.execute(
11961209
index_node_and_etag.current_node, index_node_and_etag.current_index, command, should_retry, session_info
11971210
)

ravendb/http/topology.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ def get_preferred_node_internal(cls, state: NodeSelector.__NodeSelectorState) ->
149149
server_nodes = state.nodes
150150
length = min(len(server_nodes), len(state_failures))
151151
for i in range(length):
152-
if state_failures[0] == 0:
152+
# pick the first node without failures
153+
if state_failures[i] == 0 and server_nodes[i].server_role == ServerNode.Role.MEMBER:
153154
return CurrentIndexAndNode(i, server_nodes[i])
154155
return cls.unlikely_everyone_faulted_choice(state)
155156

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import time
2+
from threading import Event
3+
from ravendb.documents.store.definition import DocumentStore
4+
from ravendb.documents.subscriptions.options import SubscriptionWorkerOptions
5+
from ravendb.exceptions.exceptions import AllTopologyNodesDownException
6+
from ravendb.infrastructure.entities import User
7+
from ravendb.serverwide.operations.common import GetDatabaseRecordOperation
8+
from ravendb.tests.test_base import TestBase
9+
10+
11+
class TestRDBC948(TestBase):
12+
def setUp(self):
13+
super().setUp()
14+
15+
def test_failover_with_invalid_dns_in_urls(self):
16+
# One invalid DNS hostname and one valid server URL (from the embedded test server)
17+
invalid_host = "http://thisnamedoesnotexist:8080"
18+
valid_url = self.store.urls[0]
19+
20+
with DocumentStore(urls=[invalid_host, valid_url], database=self.store.database) as store2:
21+
store2.conventions.disable_topology_updates = False
22+
store2.initialize()
23+
24+
# Should succeed by failing over to the valid URL
25+
with store2.open_session() as session:
26+
session.store({"Name": "John"}, "users/1")
27+
session.save_changes()
28+
29+
# Verify we can read it back (continues using the healthy node)
30+
with store2.open_session() as session:
31+
doc = session.load("users/1")
32+
self.assertIsNotNone(doc)
33+
self.assertEqual(doc.get("Name"), "John")
34+
35+
def test_all_nodes_down_throws(self):
36+
# Two unreachable endpoints: invalid DNS and a closed localhost port
37+
urls = [
38+
"http://thisnamedoesnotexist:8080",
39+
"http://127.0.0.1:1234",
40+
]
41+
42+
with DocumentStore(urls=urls, database=self.store.database) as store2:
43+
store2.conventions.disable_topology_updates = False
44+
store2.initialize()
45+
46+
with self.assertRaises(AllTopologyNodesDownException):
47+
with store2.open_session() as session:
48+
session.load("users/does-not-matter")
49+
50+
def test_maintenance_operation_failover_with_invalid_dns(self):
51+
invalid_host = "http://thisnamedoesnotexist:8080"
52+
valid_url = self.store.urls[0]
53+
database = self.store.database
54+
55+
with DocumentStore(urls=[invalid_host, valid_url], database=database) as store2:
56+
store2.conventions.disable_topology_updates = False
57+
store2.initialize()
58+
59+
# Perform maintenance call, should succeed by failing over
60+
record = store2.maintenance.server.send(GetDatabaseRecordOperation(database))
61+
self.assertIsNotNone(record)
62+
63+
def test_request_executor_failover_with_invalid_dns(self):
64+
invalid_host = "http://thisnamedoesnotexist:8080"
65+
valid_url = self.store.urls[0]
66+
67+
with DocumentStore(urls=[invalid_host, valid_url], database=self.store.database) as store2:
68+
store2.conventions.disable_topology_updates = False
69+
store2.initialize()
70+
71+
# The request executor should point to the valid URL (wait for initial topology)
72+
req_ex = store2.get_request_executor()
73+
deadline = time.time() + 5
74+
while req_ex.url is None and time.time() < deadline:
75+
time.sleep(0.05)
76+
self.assertIsNotNone(req_ex.url, "request executor URL did not initialize")
77+
self.assertTrue(req_ex.url.startswith(valid_url), f"unexpected URL: {req_ex.url}")
78+
79+
# And simple operations should succeed
80+
with store2.open_session() as session:
81+
session.store({"Name": "Jane"}, "users/2")
82+
session.save_changes()
83+
84+
def test_subscription_failover_with_invalid_dns(self):
85+
invalid_host = "http://thisnamedoesnotexist:8080"
86+
valid_url = self.store.urls[0]
87+
88+
with DocumentStore(urls=[invalid_host, valid_url], database=self.store.database) as store2:
89+
store2.conventions.disable_topology_updates = False
90+
store2.initialize()
91+
92+
# Create a subscription and ensure worker connects and receives items
93+
sub_id = store2.subscriptions.create_for_class(User)
94+
with store2.subscriptions.get_subscription_worker(SubscriptionWorkerOptions(sub_id), User) as worker:
95+
got_item = Event()
96+
97+
def _run(batch):
98+
for item in batch.items:
99+
if item.result is not None:
100+
got_item.set()
101+
102+
worker.run(_run)
103+
104+
# Add a document so the subscription has something to send
105+
with store2.open_session() as session:
106+
session.store(User(name="SubUser"))
107+
session.save_changes()
108+
109+
self.assertTrue(got_item.wait(10))

0 commit comments

Comments
 (0)