Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 67 additions & 13 deletions packages/common-library/src/common_library/error_codes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" osparc ERROR CODES (OEC)
"""osparc ERROR CODES (OEC)
Unique identifier of an exception instance
Intended to report a user about unexpected errors.
Unexpected exceptions can be traced by matching the
Expand All @@ -7,25 +7,79 @@
SEE test_error_codes for some use cases
"""

import hashlib
import re
from typing import TYPE_CHECKING, Annotated
import traceback
from datetime import UTC, datetime
from typing import Annotated, Final, TypeAlias

from pydantic import StringConstraints, TypeAdapter

_LABEL = "OEC:{}"
_PATTERN = r"OEC:\d+"
_LABEL = "OEC:{fingerprint}-{timestamp}"

if TYPE_CHECKING:
ErrorCodeStr = str
else:
ErrorCodeStr = Annotated[
str, StringConstraints(strip_whitespace=True, pattern=_PATTERN)
]
_LEN = 12 # chars (~48 bits)
_NAMED_PATTERN = re.compile(
r"OEC:(?P<fingerprint>[a-fA-F0-9]{12})-(?P<timestamp>\d{13,14})"
# NOTE: timestamp limits: 13 digits (from 2001), 14 digits (good for ~500+ years)
)
_PATTERN = re.compile(r"OEC:[a-fA-F0-9]{12}-\d{13,14}")


ErrorCodeStr: TypeAlias = Annotated[
str, StringConstraints(strip_whitespace=True, pattern=_NAMED_PATTERN)
]


def _create_fingerprint(exc: BaseException) -> str:
"""
Unique error fingerprint of the **traceback** for deduplication purposes
"""
tb = traceback.extract_tb(exc.__traceback__)
frame_sigs = [f"{frame.name}:{frame.lineno}" for frame in tb]
fingerprint = f"{type(exc).__name__}|" + "|".join(frame_sigs)
# E.g. ZeroDivisionError|foo:23|main:10
return hashlib.sha256(fingerprint.encode()).hexdigest()[:_LEN]


_SECS_TO_MILISECS: Final[int] = 1000 # ms


def _create_timestamp() -> int:
"""Timestamp as milliseconds since epoch
NOTE: this reduces the precission to milliseconds but it is good enough for our purpose
"""
ts = datetime.now(UTC).timestamp() * _SECS_TO_MILISECS
return int(ts)


def create_error_code(exception: BaseException) -> ErrorCodeStr:
return TypeAdapter(ErrorCodeStr).validate_python(_LABEL.format(id(exception)))
"""
Generates a unique error code for the given exception.

The error code follows the format: `OEC:{traceback}-{timestamp}`.
This code is intended to be shared with the front-end as a `SupportID`
for debugging and support purposes.
"""
return TypeAdapter(ErrorCodeStr).validate_python(
_LABEL.format(
fingerprint=_create_fingerprint(exception),
timestamp=_create_timestamp(),
)
)


def parse_error_codes(obj) -> list[ErrorCodeStr]:
return TypeAdapter(list[ErrorCodeStr]).validate_python(_PATTERN.findall(f"{obj}"))


def parse_error_code(obj) -> set[ErrorCodeStr]:
return set(re.findall(_PATTERN, f"{obj}"))
def parse_error_code_parts(oec: ErrorCodeStr) -> tuple[str, datetime]:
"""Returns traceback-fingerprint and timestamp from `OEC:{traceback}-{timestamp}`"""
match = _NAMED_PATTERN.match(oec)
if not match:
msg = f"Invalid error code format: {oec}"
raise ValueError(msg)
fingerprint = match.group("fingerprint")
timestamp = datetime.fromtimestamp(
float(match.group("timestamp")) / _SECS_TO_MILISECS, tz=UTC
)
return fingerprint, timestamp
69 changes: 60 additions & 9 deletions packages/common-library/tests/test_error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,68 @@
# pylint: disable=unused-variable

import logging
import time

import pytest
from common_library.error_codes import create_error_code, parse_error_code
from common_library.error_codes import (
create_error_code,
parse_error_code_parts,
parse_error_codes,
)

logger = logging.getLogger(__name__)


def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
"""use case for error-codes"""
def _level_three(v):
msg = f"An error occurred in level three with {v}"
raise RuntimeError(msg)


def _level_two(v):
_level_three(v)


def _level_one(v=None):
_level_two(v)


def test_exception_fingerprint_consistency():
error_codes = []

for v in range(2):
# emulates different runs of the same function (e.g. different sessions)
try:
_level_one(v) # same even if different value!
except Exception as err:
time.sleep(1)
error_code = create_error_code(err)
error_codes.append(error_code)

fingerprints, timestamps = list(
zip(
*[parse_error_code_parts(error_code) for error_code in error_codes],
strict=True,
)
)

assert fingerprints[0] == fingerprints[1]
assert timestamps[0] < timestamps[1]

try:
# Same function but different location
_level_one(0)
except Exception as e2:
time.sleep(1)
error_code_2 = create_error_code(e2)
fingerprint_2, timestamp_2 = parse_error_code_parts(error_code_2)

assert fingerprints[0] != fingerprint_2
assert timestamps[1] < timestamp_2


def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
with pytest.raises(RuntimeError) as exc_info:
raise RuntimeError("Something unexpected went wrong")
_level_one()

# 1. Unexpected ERROR
err = exc_info.value
Expand All @@ -33,11 +84,11 @@ def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
logger.exception("Fake Unexpected error", extra={"error_code": error_code})

# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
assert parse_error_code(
assert parse_error_codes(
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
) == {
) == [
error_code,
}
]

assert caplog.records[0].error_code == error_code
assert caplog.records[0]
Expand All @@ -49,6 +100,6 @@ def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
f"This is a user-friendly message to inform about an error. [{error_code}]"
)

assert parse_error_code(user_message) == {
assert parse_error_codes(user_message) == [
error_code,
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient
from common_library.error_codes import parse_error_code
from common_library.error_codes import parse_error_codes
from common_library.json_serialization import json_dumps
from servicelib.aiohttp import status
from servicelib.aiohttp.rest_middlewares import (
Expand All @@ -30,8 +30,7 @@ class Data:
y: str = "foo"


class SomeUnexpectedError(Exception):
...
class SomeUnexpectedError(Exception): ...


class Handlers:
Expand Down Expand Up @@ -237,7 +236,7 @@ async def test_raised_unhandled_exception(

# user friendly message with OEC reference
assert "OEC" in error["message"]
parsed_oec = parse_error_code(error["message"]).pop()
parsed_oec = parse_error_codes(error["message"]).pop()
assert (
_FMSG_INTERNAL_ERROR_USER_FRIENDLY_WITH_OEC.format(error_code=parsed_oec)
== error["message"]
Expand Down