Skip to content

Commit a41f827

Browse files
authored
Raise hydration errors on value access (#759)
* Raise hydration errors on value access * Adjust error message serialization in TestKit backend
1 parent e42bc6a commit a41f827

24 files changed

+482
-108
lines changed

docs/source/api.rst

+5
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,8 @@ Client-side errors
13231323

13241324
* :class:`neo4j.exceptions.ResultNotSingleError`
13251325

1326+
* :class:`neo4j.exceptions.BrokenRecordError`
1327+
13261328
* :class:`neo4j.exceptions.SessionExpired`
13271329

13281330
* :class:`neo4j.exceptions.ServiceUnavailable`
@@ -1360,6 +1362,9 @@ Client-side errors
13601362
.. autoclass:: neo4j.exceptions.ResultNotSingleError
13611363
:show-inheritance:
13621364

1365+
.. autoclass:: neo4j.exceptions.BrokenRecordError
1366+
:show-inheritance:
1367+
13631368
.. autoclass:: neo4j.exceptions.SessionExpired
13641369
:show-inheritance:
13651370

neo4j/__init__.py

-4
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@
108108
"BoltDriver",
109109
"Bookmark",
110110
"Bookmarks",
111-
"Config",
112111
"custom_auth",
113112
"DEFAULT_DATABASE",
114113
"Driver",
@@ -120,15 +119,13 @@
120119
"kerberos_auth",
121120
"ManagedTransaction",
122121
"Neo4jDriver",
123-
"PoolConfig",
124122
"Query",
125123
"READ_ACCESS",
126124
"Record",
127125
"Result",
128126
"ResultSummary",
129127
"ServerInfo",
130128
"Session",
131-
"SessionConfig",
132129
"SummaryCounters",
133130
"Transaction",
134131
"TRUST_ALL_CERTIFICATES",
@@ -138,7 +135,6 @@
138135
"TrustSystemCAs",
139136
"unit_of_work",
140137
"Version",
141-
"WorkspaceConfig",
142138
"WRITE_ACCESS",
143139
]
144140

neo4j/_async/work/result.py

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from warnings import warn
2121

2222
from ..._async_compat.util import AsyncUtil
23+
from ..._codec.hydration import BrokenHydrationObject
2324
from ..._data import (
2425
Record,
2526
RecordTableRowExporter,
@@ -145,6 +146,11 @@ async def on_failed_attach(metadata):
145146
def _pull(self):
146147
def on_records(records):
147148
if not self._discarding:
149+
records = (
150+
record.raw_data
151+
if isinstance(record, BrokenHydrationObject) else record
152+
for record in records
153+
)
148154
self._record_buffer.extend((
149155
Record(zip(self._keys, record))
150156
for record in records

neo4j/_codec/hydration/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717

18-
from ._common import HydrationScope
18+
from ._common import (
19+
BrokenHydrationObject,
20+
HydrationScope,
21+
)
1922
from ._interface import HydrationHandlerABC
2023

2124

2225
__all__ = [
26+
"BrokenHydrationObject",
2327
"HydrationHandlerABC",
2428
"HydrationScope",
2529
]

neo4j/_codec/hydration/_common.py

+53-4
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,46 @@
1616
# limitations under the License.
1717

1818

19+
from copy import copy
20+
1921
from ...graph import Graph
2022
from ..packstream import Structure
2123

2224

25+
class BrokenHydrationObject:
26+
"""
27+
Represents an object from the server, not understood by the driver.
28+
29+
A :class:`neo4j.Record` might contain a ``BrokenHydrationObject`` object
30+
if the driver received data from the server that it did not understand.
31+
This can for instance happen if the server sends a zoned datetime with a
32+
zone name unknown to the driver.
33+
34+
There is no need to explicitly check for this type. Any method on the
35+
:class:`neo4j.Record` that would return a ``BrokenHydrationObject``, will
36+
raise a :exc:`neo4j.exceptions.BrokenRecordError`
37+
with the original exception as cause.
38+
"""
39+
40+
def __init__(self, error, raw_data):
41+
self.error = error
42+
"The exception raised while decoding the received object."
43+
self.raw_data = raw_data
44+
"""The raw data that caused the exception."""
45+
46+
def exception_copy(self):
47+
exc_copy = copy(self.error)
48+
exc_copy.with_traceback(self.error.__traceback__)
49+
return exc_copy
50+
51+
2352
class GraphHydrator:
2453
def __init__(self):
2554
self.graph = Graph()
2655
self.struct_hydration_functions = {}
2756

2857

2958
class HydrationScope:
30-
3159
def __init__(self, hydration_handler, graph_hydrator):
3260
self._hydration_handler = hydration_handler
3361
self._graph_hydrator = graph_hydrator
@@ -37,14 +65,35 @@ def __init__(self, hydration_handler, graph_hydrator):
3765
}
3866
self.hydration_hooks = {
3967
Structure: self._hydrate_structure,
68+
list: self._hydrate_list,
69+
dict: self._hydrate_dict,
4070
}
4171
self.dehydration_hooks = hydration_handler.dehydration_functions
4272

4373
def _hydrate_structure(self, value):
4474
f = self._struct_hydration_functions.get(value.tag)
45-
if not f:
46-
return value
47-
return f(*value.fields)
75+
try:
76+
if not f:
77+
raise ValueError(
78+
f"Protocol error: unknown Structure tag: {value.tag!r}"
79+
)
80+
return f(*value.fields)
81+
except Exception as e:
82+
return BrokenHydrationObject(e, value)
83+
84+
@staticmethod
85+
def _hydrate_list(value):
86+
for v in value:
87+
if isinstance(v, BrokenHydrationObject):
88+
return BrokenHydrationObject(v.error, value)
89+
return value
90+
91+
@staticmethod
92+
def _hydrate_dict(value):
93+
for v in value.values():
94+
if isinstance(v, BrokenHydrationObject):
95+
return BrokenHydrationObject(v.error, value)
96+
return value
4897

4998
def get_graph(self):
5099
return self._graph_hydrator.graph

neo4j/_data.py

+35-6
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
from functools import reduce
2929
from operator import xor as xor_operator
3030

31+
from ._codec.hydration import BrokenHydrationObject
3132
from ._conf import iter_items
33+
from ._meta import deprecated
34+
from .exceptions import BrokenRecordError
3235
from .graph import (
3336
Node,
3437
Path,
@@ -55,9 +58,26 @@ def __new__(cls, iterable=()):
5558
inst.__keys = tuple(keys)
5659
return inst
5760

61+
def _broken_record_error(self, index):
62+
return BrokenRecordError(
63+
f"Record contains broken data at {index} ('{self.__keys[index]}')"
64+
)
65+
66+
def _super_getitem_single(self, index):
67+
value = super().__getitem__(index)
68+
if isinstance(value, BrokenHydrationObject):
69+
raise self._broken_record_error(index) from value.error
70+
return value
71+
5872
def __repr__(self):
59-
return "<%s %s>" % (self.__class__.__name__,
60-
" ".join("%s=%r" % (field, self[i]) for i, field in enumerate(self.__keys)))
73+
return "<%s %s>" % (
74+
self.__class__.__name__,
75+
" ".join("%s=%r" % (field, value)
76+
for field, value in zip(self.__keys, super().__iter__()))
77+
)
78+
79+
def __str__(self):
80+
return self.__repr__()
6181

6282
def __eq__(self, other):
6383
""" In order to be flexible regarding comparison, the equality rules
@@ -83,18 +103,26 @@ def __ne__(self, other):
83103
def __hash__(self):
84104
return reduce(xor_operator, map(hash, self.items()))
85105

106+
def __iter__(self):
107+
for i, v in enumerate(super().__iter__()):
108+
if isinstance(v, BrokenHydrationObject):
109+
raise self._broken_record_error(i) from v.error
110+
yield v
111+
86112
def __getitem__(self, key):
87113
if isinstance(key, slice):
88114
keys = self.__keys[key]
89-
values = super(Record, self).__getitem__(key)
115+
values = super().__getitem__(key)
90116
return self.__class__(zip(keys, values))
91117
try:
92118
index = self.index(key)
93119
except IndexError:
94120
return None
95121
else:
96-
return super(Record, self).__getitem__(index)
122+
return self._super_getitem_single(index)
97123

124+
# TODO: 6.0 - remove
125+
@deprecated("This method is deprecated and will be removed in the future.")
98126
def __getslice__(self, start, stop):
99127
key = slice(start, stop)
100128
keys = self.__keys[key]
@@ -114,7 +142,7 @@ def get(self, key, default=None):
114142
except ValueError:
115143
return default
116144
if 0 <= index < len(self):
117-
return super(Record, self).__getitem__(index)
145+
return self._super_getitem_single(index)
118146
else:
119147
return default
120148

@@ -197,7 +225,8 @@ def items(self, *keys):
197225
else:
198226
d.append((self.__keys[i], self[i]))
199227
return d
200-
return list((self.__keys[i], super(Record, self).__getitem__(i)) for i in range(len(self)))
228+
return list((self.__keys[i], self._super_getitem_single(i))
229+
for i in range(len(self)))
201230

202231
def data(self, *keys):
203232
""" Return the keys and values of this record as a dictionary,

neo4j/_sync/work/result.py

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from warnings import warn
2121

2222
from ..._async_compat.util import Util
23+
from ..._codec.hydration import BrokenHydrationObject
2324
from ..._data import (
2425
Record,
2526
RecordTableRowExporter,
@@ -145,6 +146,11 @@ def on_failed_attach(metadata):
145146
def _pull(self):
146147
def on_records(records):
147148
if not self._discarding:
149+
records = (
150+
record.raw_data
151+
if isinstance(record, BrokenHydrationObject) else record
152+
for record in records
153+
)
148154
self._record_buffer.extend((
149155
Record(zip(self._keys, record))
150156
for record in records

neo4j/exceptions.py

+10
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
+ ResultError
4242
+ ResultConsumedError
4343
+ ResultNotSingleError
44+
+ BrokenRecordError
4445
+ SessionExpired
4546
+ ServiceUnavailable
4647
+ RoutingServiceUnavailable
@@ -395,6 +396,15 @@ class ResultNotSingleError(ResultError):
395396
"""Raised when a result should have exactly one record but does not."""
396397

397398

399+
# DriverError > BrokenRecordError
400+
class BrokenRecordError(DriverError):
401+
""" Raised when accessing a Record's field that couldn't be decoded.
402+
403+
This can for instance happen when the server sends a zoned datetime with a
404+
zone id unknown to the client.
405+
"""
406+
407+
398408
# DriverError > SessionExpired
399409
class SessionExpired(DriverError):
400410
""" Raised when a session is no longer able to fulfil

testkitbackend/_async/backend.py

+17-6
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ def _exc_stems_from_driver(exc):
9797
if DRIVER_PATH in p.parents:
9898
return True
9999

100+
@staticmethod
101+
def _exc_msg(exc, max_depth=10):
102+
if isinstance(exc, Neo4jError) and exc.message is not None:
103+
return str(exc.message)
104+
105+
depth = 0
106+
res = str(exc)
107+
while getattr(exc, "__cause__", None) is not None:
108+
depth += 1
109+
if depth >= max_depth:
110+
break
111+
res += f"\nCaused by: {exc.__cause__!r}"
112+
exc = exc.__cause__
113+
return res
114+
100115
async def write_driver_exc(self, exc):
101116
log.debug(traceback.format_exc())
102117

@@ -109,14 +124,10 @@ async def write_driver_exc(self, exc):
109124
wrapped_exc = exc.wrapped_exc
110125
payload["errorType"] = str(type(wrapped_exc))
111126
if wrapped_exc.args:
112-
payload["msg"] = str(wrapped_exc.args[0])
127+
payload["msg"] = self._exc_msg(wrapped_exc.args[0])
113128
else:
114129
payload["errorType"] = str(type(exc))
115-
if isinstance(exc, Neo4jError) and exc.message is not None:
116-
payload["msg"] = str(exc.message)
117-
elif exc.args:
118-
payload["msg"] = str(exc.args[0])
119-
130+
payload["msg"] = self._exc_msg(exc)
120131
if isinstance(exc, Neo4jError):
121132
payload["code"] = exc.code
122133

testkitbackend/_sync/backend.py

+17-6
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ def _exc_stems_from_driver(exc):
9797
if DRIVER_PATH in p.parents:
9898
return True
9999

100+
@staticmethod
101+
def _exc_msg(exc, max_depth=10):
102+
if isinstance(exc, Neo4jError) and exc.message is not None:
103+
return str(exc.message)
104+
105+
depth = 0
106+
res = str(exc)
107+
while getattr(exc, "__cause__", None) is not None:
108+
depth += 1
109+
if depth >= max_depth:
110+
break
111+
res += f"\nCaused by: {exc.__cause__!r}"
112+
exc = exc.__cause__
113+
return res
114+
100115
def write_driver_exc(self, exc):
101116
log.debug(traceback.format_exc())
102117

@@ -109,14 +124,10 @@ def write_driver_exc(self, exc):
109124
wrapped_exc = exc.wrapped_exc
110125
payload["errorType"] = str(type(wrapped_exc))
111126
if wrapped_exc.args:
112-
payload["msg"] = str(wrapped_exc.args[0])
127+
payload["msg"] = self._exc_msg(wrapped_exc.args[0])
113128
else:
114129
payload["errorType"] = str(type(exc))
115-
if isinstance(exc, Neo4jError) and exc.message is not None:
116-
payload["msg"] = str(exc.message)
117-
elif exc.args:
118-
payload["msg"] = str(exc.args[0])
119-
130+
payload["msg"] = self._exc_msg(exc)
120131
if isinstance(exc, Neo4jError):
121132
payload["code"] = exc.code
122133

0 commit comments

Comments
 (0)