Skip to content

Commit 216cc37

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. 1. https://www.tarantool.io/en/doc/latest/dev_guide/internals/box_protocol/#responses-for-errors Part of #323
1 parent 6b3a761 commit 216cc37

File tree

11 files changed

+241
-8
lines changed

11 files changed

+241
-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 (#232).
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

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 link to a next one.
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+
22+
file: typing.Union[str, bytes]
23+
"""
24+
Source code file where error was caught.
25+
"""
26+
27+
line: int
28+
"""
29+
Line number in source code file.
30+
"""
31+
32+
message: typing.Union[str, bytes]
33+
"""
34+
Text of reason.
35+
"""
36+
37+
errno: int
38+
"""
39+
Ordinal number of the error.
40+
"""
41+
42+
errcode: int
43+
"""
44+
Number of the error as defined in ``errcode.h``.
45+
"""
46+
47+
fields: typing.Optional[dict] = None
48+
"""
49+
Additional fields depending on error type. For example, if
50+
:attr:`~tarantool.types.BoxError.type` is ``"AccessDeniedError"``,
51+
then it will include ``"object_type"``, ``"object_name"``,
52+
``"access_type"``.
53+
"""
54+
55+
prev: typing.Optional[typing.List['BoxError']] = None
56+
"""
57+
Previous error in stack.
58+
"""
59+
60+
61+
MP_ERROR_STACK = 0x00
62+
MP_ERROR_TYPE = 0x00
63+
MP_ERROR_FILE = 0x01
64+
MP_ERROR_LINE = 0x02
65+
MP_ERROR_MESSAGE = 0x03
66+
MP_ERROR_ERRNO = 0x04
67+
MP_ERROR_ERRCODE = 0x05
68+
MP_ERROR_FIELDS = 0x06
69+
70+
def decode_box_error(err_map):
71+
"""
72+
Decode MessagePack map received from Tarantool to `box.error`_
73+
object representation.
74+
75+
:param err_map: Error MessagePack map received from Tarantool.
76+
:type err_map: :obj:`dict`
77+
78+
:rtype: :class:`~tarantool.BoxError`
79+
80+
:raises: :exc:`KeyError`
81+
"""
82+
83+
encoded_stack = err_map[MP_ERROR_STACK]
84+
85+
prev = None
86+
for item in encoded_stack[::-1]:
87+
err = BoxError(
88+
type=item[MP_ERROR_TYPE],
89+
file=item[MP_ERROR_FILE],
90+
line=item[MP_ERROR_LINE],
91+
message=item[MP_ERROR_MESSAGE],
92+
errno=item[MP_ERROR_ERRNO],
93+
errcode=item[MP_ERROR_ERRCODE],
94+
fields=item.get(MP_ERROR_FIELDS), # omitted if empty
95+
prev=prev,
96+
)
97+
prev = err
98+
99+
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)