Skip to content

Commit

Permalink
iproto: support errors extra information
Browse files Browse the repository at this point in the history
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
  • Loading branch information
DifferentialOrange committed Oct 24, 2022
1 parent 8118e7d commit 88e9299
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

### Changed
- Bump msgpack requirement to 1.0.4 (PR #223).
Expand Down
4 changes: 4 additions & 0 deletions docs/source/api/submodule-types.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module :py:mod:`tarantool.types`
================================

.. automodule:: tarantool.types
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ API Reference
api/submodule-response.rst
api/submodule-schema.rst
api/submodule-space.rst
api/submodule-types.rst
api/submodule-utils.rst

.. Indices and tables
Expand Down
4 changes: 3 additions & 1 deletion tarantool/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
IPROTO_OPS = 0x28
#
IPROTO_DATA = 0x30
IPROTO_ERROR = 0x31
IPROTO_ERROR_24 = 0x31
#
IPROTO_METADATA = 0x32
IPROTO_SQL_TEXT = 0x40
Expand All @@ -36,6 +36,8 @@
IPROTO_SQL_INFO_ROW_COUNT = 0x00
IPROTO_SQL_INFO_AUTOINCREMENT_IDS = 0x01
#
IPROTO_ERROR = 0x52
#
IPROTO_VERSION = 0x54
IPROTO_FEATURES = 0x55

Expand Down
15 changes: 12 additions & 3 deletions tarantool/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,17 @@ class DatabaseError(Error):
Exception raised for errors that are related to the database.
"""

def __init__(self, *args):
def __init__(self, *args, extra_info=None):
"""
:param args: ``(code, message)`` or ``(message,)``.
:type args: :obj:`tuple`
:param extra_info: Additional `box.error`_ information
with backtrace.
:type extra_info: :class:`~tarantool.types.BoxError` or
:obj:`None`, optional
.. _box.error: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_error/error/
"""

super().__init__(*args)
Expand All @@ -59,6 +66,8 @@ def __init__(self, *args):
self.code = 0
self.message = ''

self.extra_info = extra_info


class DataError(DatabaseError):
"""
Expand Down Expand Up @@ -235,7 +244,7 @@ class NetworkError(DatabaseError):
Error related to network.
"""

def __init__(self, orig_exception=None, *args):
def __init__(self, orig_exception=None, *args, **kwargs):
"""
:param orig_exception: Exception to wrap.
:type orig_exception: optional
Expand All @@ -256,7 +265,7 @@ def __init__(self, orig_exception=None, *args):
super(NetworkError, self).__init__(
orig_exception.errno, self.message)
else:
super(NetworkError, self).__init__(orig_exception, *args)
super(NetworkError, self).__init__(orig_exception, *args, **kwargs)


class NetworkWarning(UserWarning):
Expand Down
14 changes: 12 additions & 2 deletions tarantool/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from tarantool.const import (
IPROTO_REQUEST_TYPE,
IPROTO_DATA,
IPROTO_ERROR_24,
IPROTO_ERROR,
IPROTO_SYNC,
IPROTO_SCHEMA_ID,
Expand All @@ -21,6 +22,7 @@
IPROTO_VERSION,
IPROTO_FEATURES,
)
from tarantool.types import decode_box_error
from tarantool.error import (
DatabaseError,
InterfaceError,
Expand Down Expand Up @@ -117,14 +119,22 @@ def __init__(self, conn, response):
# self.append(self._data)
else:
# Separate return_code and completion_code
self._return_message = self._body.get(IPROTO_ERROR, "")
self._return_message = self._body.get(IPROTO_ERROR_24, "")
self._return_code = self._code & (REQUEST_TYPE_ERROR - 1)

self._return_error = None
return_error_map = self._body.get(IPROTO_ERROR)
if return_error_map is not None:
self._return_error = decode_box_error(return_error_map)

self._data = []
if self._return_code == 109:
raise SchemaReloadException(self._return_message,
self._schema_version)
if self.conn.error:
raise DatabaseError(self._return_code, self._return_message)
raise DatabaseError(self._return_code,
self._return_message,
extra_info=self._return_error)

def __getitem__(self, idx):
if self._data is None:
Expand Down
108 changes: 108 additions & 0 deletions tarantool/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
Additional Tarantool type definitions.
"""

import typing
from dataclasses import dataclass

@dataclass
class BoxError():
"""
Type representing Tarantool `box.error`_ object: a single
MP_ERROR_STACK object with a link to the previous stack error.
.. _box.error: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_error/error/
"""

type: typing.Union[str, bytes]
"""
Type that implies source, for example ``"ClientError"``.
Value type depends on :class:`~tarantool.Connection`
:paramref:`~tarantool.Connection.params.encoding`.
"""

file: typing.Union[str, bytes]
"""
Source code file where error was caught.
Value type depends on :class:`~tarantool.Connection`
:paramref:`~tarantool.Connection.params.encoding`.
"""

line: int
"""
Line number in source code file.
"""

message: typing.Union[str, bytes]
"""
Text of reason.
Value type depends on :class:`~tarantool.Connection`
:paramref:`~tarantool.Connection.params.encoding`.
"""

errno: int
"""
Ordinal number of the error.
"""

errcode: int
"""
Number of the error as defined in ``errcode.h``.
"""

fields: typing.Optional[dict] = None
"""
Additional fields depending on error type. For example, if
:attr:`~tarantool.types.BoxError.type` is ``"AccessDeniedError"``,
then it will include ``"object_type"``, ``"object_name"``,
``"access_type"``.
"""

prev: typing.Optional[typing.List['BoxError']] = None
"""
Previous error in stack.
"""


MP_ERROR_STACK = 0x00
MP_ERROR_TYPE = 0x00
MP_ERROR_FILE = 0x01
MP_ERROR_LINE = 0x02
MP_ERROR_MESSAGE = 0x03
MP_ERROR_ERRNO = 0x04
MP_ERROR_ERRCODE = 0x05
MP_ERROR_FIELDS = 0x06

def decode_box_error(err_map):
"""
Decode MessagePack map received from Tarantool to `box.error`_
object representation.
:param err_map: Error MessagePack map received from Tarantool.
:type err_map: :obj:`dict`
:rtype: :class:`~tarantool.BoxError`
:raises: :exc:`KeyError`
"""

encoded_stack = err_map[MP_ERROR_STACK]

prev = None
for item in encoded_stack[::-1]:
err = BoxError(
type=item[MP_ERROR_TYPE],
file=item[MP_ERROR_FILE],
line=item[MP_ERROR_LINE],
message=item[MP_ERROR_MESSAGE],
errno=item[MP_ERROR_ERRNO],
errcode=item[MP_ERROR_ERRCODE],
fields=item.get(MP_ERROR_FIELDS), # omitted if empty
prev=prev,
)
prev = err

return prev
3 changes: 2 additions & 1 deletion test/suites/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ def load_tests(loader, tests, pattern):

os.chdir(__tmp)


# Workaround to disable unittest output truncating
__import__('sys').modules['unittest.util']._MAX_LENGTH = 99999
11 changes: 11 additions & 0 deletions test/suites/lib/skip.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,14 @@ def skip_or_run_datetime_test(func):

return skip_or_run_test_pcall_require(func, 'datetime',
'does not support datetime type')

def skip_or_run_error_extra_info_test(func):
"""Decorator to skip or run tests related to extra error info
provided over iproto depending on the tarantool version.
Tarantool provides extra error info only since 2.4.1 version.
See https://github.com/tarantool/tarantool/issues/4398
"""

return skip_or_run_test_tarantool(func, '2.4.1',
'does not provide extra error info')
73 changes: 73 additions & 0 deletions test/suites/test_dml.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import sys
import unittest
import tarantool
from tarantool.error import DatabaseError

from .lib.tarantool_server import TarantoolServer
from .lib.skip import skip_or_run_error_extra_info_test

class TestSuite_Request(unittest.TestCase):
@classmethod
Expand Down Expand Up @@ -325,6 +327,77 @@ def test_14_idempotent_close(self):
con.close()
self.assertEqual(con.is_closed(), True)

@skip_or_run_error_extra_info_test
def test_14_extra_error_info(self):
try:
self.con.eval("not a Lua code")
except DatabaseError as exc:
self.assertEqual(exc.extra_info.type, 'LuajitError')
self.assertRegex(exc.extra_info.file, r'/tarantool')
self.assertTrue(exc.extra_info.line > 0)
self.assertEqual(exc.extra_info.message, "eval:1: unexpected symbol near 'not'")
self.assertEqual(exc.extra_info.errno, 0)
self.assertEqual(exc.extra_info.errcode, 32)
self.assertEqual(exc.extra_info.fields, None)
self.assertEqual(exc.extra_info.prev, None)
else:
self.fail('Expected error')

@skip_or_run_error_extra_info_test
def test_15_extra_error_info_stacked(self):
try:
self.con.eval(r"""
local e1 = box.error.new(box.error.UNKNOWN)
local e2 = box.error.new(box.error.TIMEOUT)
e2:set_prev(e1)
error(e2)
""")
except DatabaseError as exc:
self.assertEqual(exc.extra_info.type, 'ClientError')
self.assertRegex(exc.extra_info.file, 'eval')
self.assertEqual(exc.extra_info.line, 3)
self.assertEqual(exc.extra_info.message, "Timeout exceeded")
self.assertEqual(exc.extra_info.errno, 0)
self.assertEqual(exc.extra_info.errcode, 78)
self.assertEqual(exc.extra_info.fields, None)
self.assertNotEqual(exc.extra_info.prev, None)
prev = exc.extra_info.prev
self.assertEqual(prev.type, 'ClientError')
self.assertEqual(prev.file, 'eval')
self.assertEqual(prev.line, 2)
self.assertEqual(prev.message, "Unknown error")
self.assertEqual(prev.errno, 0)
self.assertEqual(prev.errcode, 0)
self.assertEqual(prev.fields, None)
else:
self.fail('Expected error')

@skip_or_run_error_extra_info_test
def test_16_extra_error_info_fields(self):
try:
self.con.eval("""
box.schema.func.create('forbidden_function')
""")
except DatabaseError as exc:
self.assertEqual(exc.extra_info.type, 'AccessDeniedError')
self.assertRegex(exc.extra_info.file, r'/tarantool')
self.assertTrue(exc.extra_info.line > 0)
self.assertEqual(
exc.extra_info.message,
"Create access to function 'forbidden_function' is denied for user 'test'")
self.assertEqual(exc.extra_info.errno, 0)
self.assertEqual(exc.extra_info.errcode, 42)
self.assertEqual(
exc.extra_info.fields,
{
'object_type': 'function',
'object_name': 'forbidden_function',
'access_type': 'Create'
})
self.assertEqual(exc.extra_info.prev, None)
else:
self.fail('Expected error')

@classmethod
def tearDownClass(self):
self.con.close()
Expand Down
24 changes: 23 additions & 1 deletion test/suites/test_encoding.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import sys
import unittest

import tarantool
from tarantool.error import DatabaseError

from .lib.skip import skip_or_run_varbinary_test
from .lib.skip import skip_or_run_varbinary_test, skip_or_run_error_extra_info_test
from .lib.tarantool_server import TarantoolServer

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

@skip_or_run_error_extra_info_test
def test_01_05_error_extra_info_decode_for_encoding_utf8_behavior(self):
try:
self.con_encoding_utf8.eval("not a Lua code")
except DatabaseError as exc:
self.assertEqual(exc.extra_info.type, 'LuajitError')
self.assertEqual(exc.extra_info.message, "eval:1: unexpected symbol near 'not'")
else:
self.fail('Expected error')

@skip_or_run_error_extra_info_test
def test_02_05_error_extra_info_decode_for_encoding_none_behavior(self):
try:
self.con_encoding_none.eval("not a Lua code")
except DatabaseError as exc:
self.assertEqual(exc.extra_info.type, b'LuajitError')
self.assertEqual(exc.extra_info.message, b"eval:1: unexpected symbol near 'not'")
else:
self.fail('Expected error')

@classmethod
def tearDownClass(self):
for con in self.conns:
Expand Down

0 comments on commit 88e9299

Please sign in to comment.