Skip to content

Commit 1cdbfac

Browse files
committed
add TransientError and Memgraph vendor
1 parent 7637274 commit 1cdbfac

5 files changed

Lines changed: 111 additions & 7 deletions

File tree

gqlalchemy/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@
3737
wait_for_docker_container,
3838
wait_for_port,
3939
)
40-
from gqlalchemy.exceptions import GQLAlchemyError, GQLAlchemyWarning # noqa F401
40+
from gqlalchemy.exceptions import ( # noqa F401
41+
GQLAlchemyError,
42+
GQLAlchemyWarning,
43+
GQLAlchemyDatabaseError,
44+
GQLAlchemyTransientError,
45+
)
4146

4247
from gqlalchemy.query_builders import ( # noqa F401
4348
neo4j_query_builder,

gqlalchemy/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from enum import Enum
1616
import time
1717

18+
import mgclient
19+
1820
DATABASE_MISSING_IN_FIELD_ERROR_MESSAGE = """
1921
Can't have an index on a property without providing the database `db` object.
2022
Define your property as:
@@ -172,6 +174,12 @@ def __init__(self, message):
172174
self.message = message
173175

174176

177+
class GQLAlchemyTransientError(GQLAlchemyDatabaseError):
178+
"""A database error that is worth retrying."""
179+
180+
pass
181+
182+
175183
class GQLAlchemyOperatorTypeError(GQLAlchemyError):
176184
def __init__(self, clause) -> None:
177185
self.message = OPERATOR_TYPE_ERROR.format(clause=clause)
@@ -212,6 +220,8 @@ def database_error_handler(func):
212220
def inner_function(*args, **kwargs):
213221
try:
214222
return func(*args, **kwargs)
223+
except mgclient.TransientError as e:
224+
raise GQLAlchemyTransientError(e) from e
215225
except Exception as e:
216226
raise GQLAlchemyDatabaseError(e) from e
217227

gqlalchemy/vendors/memgraph.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,17 @@ def __init__(
122122
encrypted: bool = mg_consts.MG_ENCRYPTED,
123123
client_name: str = mg_consts.MG_CLIENT_NAME,
124124
lazy: bool = mg_consts.MG_LAZY,
125+
routing: bool = False,
126+
access_mode: Optional[str] = None,
127+
resolver: Optional[object] = None,
125128
):
126129
super().__init__(
127130
host=host, port=port, username=username, password=password, encrypted=encrypted, client_name=client_name
128131
)
129132
self._lazy = lazy
133+
self._routing = routing
134+
self._access_mode = access_mode
135+
self._resolver = resolver
130136
self._on_disk_db = None
131137

132138
@staticmethod
@@ -228,6 +234,9 @@ def new_connection(self) -> Connection:
228234
password=self._password,
229235
encrypted=self._encrypted,
230236
client_name=self._client_name,
237+
routing=self._routing,
238+
access_mode=self._access_mode,
239+
resolver=self._resolver,
231240
)
232241
return MemgraphConnection(**args)
233242

@@ -301,6 +310,9 @@ def _new_connection(self) -> Connection:
301310
password=self._password,
302311
encrypted=self._encrypted,
303312
client_name=self._client_name,
313+
routing=self._routing,
314+
access_mode=self._access_mode,
315+
resolver=self._resolver,
304316
)
305317
return MemgraphConnection(**args)
306318

tests/integration/test_routing.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@
1717
These tests need a running HA cluster whose coordinator is advertised through
1818
``MEMGRAPH_HA_COORDINATOR_HOST`` / ``MEMGRAPH_HA_COORDINATOR_PORT`` (started in
1919
CI by the "Run Memgraph HA Cluster" step, ``scripts/ha_cluster.sh``, or locally
20-
by running that script). They are skipped otherwise, and also when the
21-
installed pymgclient predates client-side routing.
20+
by running that script). They are skipped otherwise.
2221
"""
2322

2423
import os
2524

2625
import mgclient
2726
import pytest
2827

28+
from gqlalchemy import Memgraph
2929
from gqlalchemy.connection import MemgraphConnection
3030

3131
HA_HOST = os.environ.get("MEMGRAPH_HA_COORDINATOR_HOST")
@@ -37,10 +37,6 @@
3737
not (HA_HOST and HA_PORT),
3838
reason="requires a Memgraph HA cluster (set MEMGRAPH_HA_COORDINATOR_HOST/PORT)",
3939
),
40-
pytest.mark.skipif(
41-
not hasattr(mgclient, "ACCESS_MODE_WRITE"),
42-
reason="requires a routing-capable pymgclient",
43-
),
4440
]
4541

4642

@@ -84,3 +80,27 @@ def test_routed_write_is_readable_from_main():
8480
connection.execute("MERGE (n:RoutingTest {id: 1}) SET n.value = 'ok'")
8581
result = list(connection.execute_and_fetch("MATCH (n:RoutingTest {id: 1}) RETURN n.value AS value"))
8682
assert result[0]["value"] == "ok"
83+
84+
85+
# The Memgraph vendor client (routing plumbed through new_connection).
86+
87+
88+
def _routing_memgraph(access_mode=None):
89+
return Memgraph(host=HA_HOST, port=int(HA_PORT), routing=True, access_mode=access_mode)
90+
91+
92+
def _vendor_role(db):
93+
row = list(db.execute_and_fetch("SHOW REPLICATION ROLE"))[0]
94+
return next(iter(row.values()))
95+
96+
97+
def test_vendor_write_client_targets_main():
98+
assert _vendor_role(_routing_memgraph(mgclient.ACCESS_MODE_WRITE)) == "main"
99+
100+
101+
def test_vendor_read_client_targets_replica():
102+
assert _vendor_role(_routing_memgraph(mgclient.ACCESS_MODE_READ)) == "replica"
103+
104+
105+
def test_vendor_default_access_mode_targets_main():
106+
assert _vendor_role(_routing_memgraph()) == "main"

tests/test_exceptions.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (c) 2016-2026 Memgraph Ltd. [https://memgraph.com]
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import mgclient
16+
import pytest
17+
18+
from gqlalchemy.exceptions import (
19+
GQLAlchemyDatabaseError,
20+
GQLAlchemyTransientError,
21+
database_error_handler,
22+
)
23+
24+
25+
def test_transient_error_is_a_database_error():
26+
# Backwards compatible: code catching GQLAlchemyDatabaseError still catches
27+
# the transient subclass.
28+
assert issubclass(GQLAlchemyTransientError, GQLAlchemyDatabaseError)
29+
30+
31+
def test_transient_error_is_mapped_to_gqlalchemy_transient_error():
32+
@database_error_handler
33+
def boom():
34+
raise mgclient.TransientError("instance briefly unreachable during a failover")
35+
36+
with pytest.raises(GQLAlchemyTransientError):
37+
boom()
38+
39+
40+
def test_non_transient_database_error_is_not_transient():
41+
@database_error_handler
42+
def boom():
43+
raise mgclient.DatabaseError("syntax error")
44+
45+
with pytest.raises(GQLAlchemyDatabaseError) as exc_info:
46+
boom()
47+
assert not isinstance(exc_info.value, GQLAlchemyTransientError)
48+
49+
50+
def test_plain_exception_is_mapped_to_database_error():
51+
@database_error_handler
52+
def boom():
53+
raise ValueError("not a database error")
54+
55+
with pytest.raises(GQLAlchemyDatabaseError) as exc_info:
56+
boom()
57+
assert not isinstance(exc_info.value, GQLAlchemyTransientError)

0 commit comments

Comments
 (0)