Skip to content

Commit 2a38d73

Browse files
iproto: support errors extra information
Since Tarantool 2.4.1, iproto error responses contain extra info with backtrace. After this patch, DatabaseError would contain `extra_info` property, if it was provided. Error extra information is parsed based on common encoder/decoder rules. String fields are converted to either `str` or `bytes` based on `encoding` mode. 1. https://www.tarantool.io/en/doc/latest/dev_guide/internals/box_protocol/#responses-for-errors Part of #232
1 parent 53854a8 commit 2a38d73

File tree

11 files changed

+250
-8
lines changed

11 files changed

+250
-8
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
136136

137137
- Support iproto feature discovery (#206).
138138
- Backport ConnectionPool support for Python 3.6.
139+
- Support extra information for iproto errors (#232).
139140

140141
### Changed
141142
- Bump msgpack requirement to 1.0.4 (PR #223).

Diff for: doc/api/submodule-types.rst

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module :py:mod:`tarantool.types`
2+
================================
3+
4+
.. automodule:: tarantool.types

Diff for: doc/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ API Reference
4343
api/submodule-response.rst
4444
api/submodule-schema.rst
4545
api/submodule-space.rst
46+
api/submodule-types.rst
4647
api/submodule-utils.rst
4748

4849
.. Indices and tables

Diff for: tarantool/const.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
IPROTO_OPS = 0x28
2828
#
2929
IPROTO_DATA = 0x30
30-
IPROTO_ERROR = 0x31
30+
IPROTO_ERROR_24 = 0x31
3131
#
3232
IPROTO_METADATA = 0x32
3333
IPROTO_SQL_TEXT = 0x40
@@ -36,6 +36,8 @@
3636
IPROTO_SQL_INFO_ROW_COUNT = 0x00
3737
IPROTO_SQL_INFO_AUTOINCREMENT_IDS = 0x01
3838
#
39+
IPROTO_ERROR = 0x52
40+
#
3941
IPROTO_VERSION = 0x54
4042
IPROTO_FEATURES = 0x55
4143

Diff for: tarantool/error.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,17 @@ class DatabaseError(Error):
4141
Exception raised for errors that are related to the database.
4242
"""
4343

44-
def __init__(self, *args):
44+
def __init__(self, *args, extra_info=None):
4545
"""
4646
:param args: ``(code, message)`` or ``(message,)``.
4747
:type args: :obj:`tuple`
48+
49+
:param extra_info: Additional `box.error`_ information
50+
with backtrace.
51+
:type extra_info: :class:`~tarantool.types.BoxError` or
52+
:obj:`None`, optional
53+
54+
.. _box.error: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_error/error/
4855
"""
4956

5057
super().__init__(*args)
@@ -59,6 +66,8 @@ def __init__(self, *args):
5966
self.code = 0
6067
self.message = ''
6168

69+
self.extra_info = extra_info
70+
6271

6372
class DataError(DatabaseError):
6473
"""
@@ -235,7 +244,7 @@ class NetworkError(DatabaseError):
235244
Error related to network.
236245
"""
237246

238-
def __init__(self, orig_exception=None, *args):
247+
def __init__(self, orig_exception=None, *args, **kwargs):
239248
"""
240249
:param orig_exception: Exception to wrap.
241250
:type orig_exception: optional
@@ -256,7 +265,7 @@ def __init__(self, orig_exception=None, *args):
256265
super(NetworkError, self).__init__(
257266
orig_exception.errno, self.message)
258267
else:
259-
super(NetworkError, self).__init__(orig_exception, *args)
268+
super(NetworkError, self).__init__(orig_exception, *args, **kwargs)
260269

261270

262271
class NetworkWarning(UserWarning):

Diff for: tarantool/response.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tarantool.const import (
1212
IPROTO_REQUEST_TYPE,
1313
IPROTO_DATA,
14+
IPROTO_ERROR_24,
1415
IPROTO_ERROR,
1516
IPROTO_SYNC,
1617
IPROTO_SCHEMA_ID,
@@ -21,6 +22,7 @@
2122
IPROTO_VERSION,
2223
IPROTO_FEATURES,
2324
)
25+
from tarantool.types import decode_box_error
2426
from tarantool.error import (
2527
DatabaseError,
2628
InterfaceError,
@@ -117,14 +119,22 @@ def __init__(self, conn, response):
117119
# self.append(self._data)
118120
else:
119121
# Separate return_code and completion_code
120-
self._return_message = self._body.get(IPROTO_ERROR, "")
122+
self._return_message = self._body.get(IPROTO_ERROR_24, "")
121123
self._return_code = self._code & (REQUEST_TYPE_ERROR - 1)
124+
125+
self._return_error = None
126+
return_error_map = self._body.get(IPROTO_ERROR)
127+
if return_error_map is not None:
128+
self._return_error = decode_box_error(return_error_map)
129+
122130
self._data = []
123131
if self._return_code == 109:
124132
raise SchemaReloadException(self._return_message,
125133
self._schema_version)
126134
if self.conn.error:
127-
raise DatabaseError(self._return_code, self._return_message)
135+
raise DatabaseError(self._return_code,
136+
self._return_message,
137+
extra_info=self._return_error)
128138

129139
def __getitem__(self, idx):
130140
if self._data is None:

Diff for: tarantool/types.py

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Additional Tarantool type definitions.
3+
"""
4+
5+
import typing
6+
from dataclasses import dataclass
7+
8+
@dataclass
9+
class BoxError():
10+
"""
11+
Type representing Tarantool `box.error`_ object: a single
12+
MP_ERROR_STACK object with a link to the previous stack error.
13+
14+
.. _box.error: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_error/error/
15+
"""
16+
17+
type: typing.Union[str, bytes]
18+
"""
19+
Type that implies source, for example ``"ClientError"``.
20+
21+
Value type depends on :class:`~tarantool.Connection`
22+
:paramref:`~tarantool.Connection.params.encoding`.
23+
"""
24+
25+
file: typing.Union[str, bytes]
26+
"""
27+
Source code file where error was caught.
28+
29+
Value type depends on :class:`~tarantool.Connection`
30+
:paramref:`~tarantool.Connection.params.encoding`.
31+
"""
32+
33+
line: int
34+
"""
35+
Line number in source code file.
36+
"""
37+
38+
message: typing.Union[str, bytes]
39+
"""
40+
Text of reason.
41+
42+
Value type depends on :class:`~tarantool.Connection`
43+
:paramref:`~tarantool.Connection.params.encoding`.
44+
"""
45+
46+
errno: int
47+
"""
48+
Ordinal number of the error.
49+
"""
50+
51+
errcode: int
52+
"""
53+
Number of the error as defined in ``errcode.h``.
54+
"""
55+
56+
fields: typing.Optional[dict] = None
57+
"""
58+
Additional fields depending on error type. For example, if
59+
:attr:`~tarantool.types.BoxError.type` is ``"AccessDeniedError"``,
60+
then it will include ``"object_type"``, ``"object_name"``,
61+
``"access_type"``.
62+
"""
63+
64+
prev: typing.Optional[typing.List['BoxError']] = None
65+
"""
66+
Previous error in stack.
67+
"""
68+
69+
70+
MP_ERROR_STACK = 0x00
71+
MP_ERROR_TYPE = 0x00
72+
MP_ERROR_FILE = 0x01
73+
MP_ERROR_LINE = 0x02
74+
MP_ERROR_MESSAGE = 0x03
75+
MP_ERROR_ERRNO = 0x04
76+
MP_ERROR_ERRCODE = 0x05
77+
MP_ERROR_FIELDS = 0x06
78+
79+
def decode_box_error(err_map):
80+
"""
81+
Decode MessagePack map received from Tarantool to `box.error`_
82+
object representation.
83+
84+
:param err_map: Error MessagePack map received from Tarantool.
85+
:type err_map: :obj:`dict`
86+
87+
:rtype: :class:`~tarantool.BoxError`
88+
89+
:raises: :exc:`KeyError`
90+
"""
91+
92+
encoded_stack = err_map[MP_ERROR_STACK]
93+
94+
prev = None
95+
for item in encoded_stack[::-1]:
96+
err = BoxError(
97+
type=item[MP_ERROR_TYPE],
98+
file=item[MP_ERROR_FILE],
99+
line=item[MP_ERROR_LINE],
100+
message=item[MP_ERROR_MESSAGE],
101+
errno=item[MP_ERROR_ERRNO],
102+
errcode=item[MP_ERROR_ERRCODE],
103+
fields=item.get(MP_ERROR_FIELDS), # omitted if empty
104+
prev=prev,
105+
)
106+
prev = err
107+
108+
return prev

Diff for: test/suites/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ def load_tests(loader, tests, pattern):
3737

3838
os.chdir(__tmp)
3939

40-
40+
# Workaround to disable unittest output truncating
41+
__import__('sys').modules['unittest.util']._MAX_LENGTH = 99999

Diff for: test/suites/lib/skip.py

+11
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,14 @@ def skip_or_run_datetime_test(func):
154154

155155
return skip_or_run_test_pcall_require(func, 'datetime',
156156
'does not support datetime type')
157+
158+
def skip_or_run_error_extra_info_test(func):
159+
"""Decorator to skip or run tests related to extra error info
160+
provided over iproto depending on the tarantool version.
161+
162+
Tarantool provides extra error info only since 2.4.1 version.
163+
See https://github.com/tarantool/tarantool/issues/4398
164+
"""
165+
166+
return skip_or_run_test_tarantool(func, '2.4.1',
167+
'does not provide extra error info')

Diff for: test/suites/test_dml.py

+73
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import sys
22
import unittest
33
import tarantool
4+
from tarantool.error import DatabaseError
45

56
from .lib.tarantool_server import TarantoolServer
7+
from .lib.skip import skip_or_run_error_extra_info_test
68

79
class TestSuite_Request(unittest.TestCase):
810
@classmethod
@@ -309,6 +311,77 @@ def test_13_unix_socket_connect(self):
309311
self.sock_con = tarantool.connect(self.sock_srv.host, self.sock_srv.args['primary'])
310312
self.assertEqual(self.sock_con.ping(notime=True), "Success")
311313

314+
@skip_or_run_error_extra_info_test
315+
def test_14_extra_error_info(self):
316+
try:
317+
self.con.eval("not a Lua code")
318+
except DatabaseError as exc:
319+
self.assertEqual(exc.extra_info.type, 'LuajitError')
320+
self.assertRegex(exc.extra_info.file, r'/tarantool')
321+
self.assertTrue(exc.extra_info.line > 0)
322+
self.assertEqual(exc.extra_info.message, "eval:1: unexpected symbol near 'not'")
323+
self.assertEqual(exc.extra_info.errno, 0)
324+
self.assertEqual(exc.extra_info.errcode, 32)
325+
self.assertEqual(exc.extra_info.fields, None)
326+
self.assertEqual(exc.extra_info.prev, None)
327+
else:
328+
self.fail('Expected error')
329+
330+
@skip_or_run_error_extra_info_test
331+
def test_15_extra_error_info_stacked(self):
332+
try:
333+
self.con.eval(r"""
334+
local e1 = box.error.new(box.error.UNKNOWN)
335+
local e2 = box.error.new(box.error.TIMEOUT)
336+
e2:set_prev(e1)
337+
error(e2)
338+
""")
339+
except DatabaseError as exc:
340+
self.assertEqual(exc.extra_info.type, 'ClientError')
341+
self.assertRegex(exc.extra_info.file, 'eval')
342+
self.assertEqual(exc.extra_info.line, 3)
343+
self.assertEqual(exc.extra_info.message, "Timeout exceeded")
344+
self.assertEqual(exc.extra_info.errno, 0)
345+
self.assertEqual(exc.extra_info.errcode, 78)
346+
self.assertEqual(exc.extra_info.fields, None)
347+
self.assertNotEqual(exc.extra_info.prev, None)
348+
prev = exc.extra_info.prev
349+
self.assertEqual(prev.type, 'ClientError')
350+
self.assertEqual(prev.file, 'eval')
351+
self.assertEqual(prev.line, 2)
352+
self.assertEqual(prev.message, "Unknown error")
353+
self.assertEqual(prev.errno, 0)
354+
self.assertEqual(prev.errcode, 0)
355+
self.assertEqual(prev.fields, None)
356+
else:
357+
self.fail('Expected error')
358+
359+
@skip_or_run_error_extra_info_test
360+
def test_16_extra_error_info_fields(self):
361+
try:
362+
self.con.eval("""
363+
box.schema.func.create('forbidden_function')
364+
""")
365+
except DatabaseError as exc:
366+
self.assertEqual(exc.extra_info.type, 'AccessDeniedError')
367+
self.assertRegex(exc.extra_info.file, r'/tarantool')
368+
self.assertTrue(exc.extra_info.line > 0)
369+
self.assertEqual(
370+
exc.extra_info.message,
371+
"Create access to function 'forbidden_function' is denied for user 'test'")
372+
self.assertEqual(exc.extra_info.errno, 0)
373+
self.assertEqual(exc.extra_info.errcode, 42)
374+
self.assertEqual(
375+
exc.extra_info.fields,
376+
{
377+
'object_type': 'function',
378+
'object_name': 'forbidden_function',
379+
'access_type': 'Create'
380+
})
381+
self.assertEqual(exc.extra_info.prev, None)
382+
else:
383+
self.fail('Expected error')
384+
312385
@classmethod
313386
def tearDownClass(self):
314387
self.con.close()

Diff for: test/suites/test_encoding.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import sys
22
import unittest
3+
34
import tarantool
5+
from tarantool.error import DatabaseError
46

5-
from .lib.skip import skip_or_run_varbinary_test
7+
from .lib.skip import skip_or_run_varbinary_test, skip_or_run_error_extra_info_test
68
from .lib.tarantool_server import TarantoolServer
79

810
class TestSuite_Encoding(unittest.TestCase):
@@ -172,6 +174,26 @@ def test_02_04_varbinary_decode_for_encoding_none_behavior(self):
172174
""" % (space, data_hex))
173175
self.assertSequenceEqual(resp, [[data_id, data]])
174176

177+
@skip_or_run_error_extra_info_test
178+
def test_01_05_error_extra_info_decode_for_encoding_utf8_behavior(self):
179+
try:
180+
self.con_encoding_utf8.eval("not a Lua code")
181+
except DatabaseError as exc:
182+
self.assertEqual(exc.extra_info.type, 'LuajitError')
183+
self.assertEqual(exc.extra_info.message, "eval:1: unexpected symbol near 'not'")
184+
else:
185+
self.fail('Expected error')
186+
187+
@skip_or_run_error_extra_info_test
188+
def test_02_05_error_extra_info_decode_for_encoding_none_behavior(self):
189+
try:
190+
self.con_encoding_none.eval("not a Lua code")
191+
except DatabaseError as exc:
192+
self.assertEqual(exc.extra_info.type, b'LuajitError')
193+
self.assertEqual(exc.extra_info.message, b"eval:1: unexpected symbol near 'not'")
194+
else:
195+
self.fail('Expected error')
196+
175197
@classmethod
176198
def tearDownClass(self):
177199
for con in self.conns:

0 commit comments

Comments
 (0)