Skip to content

Commit df87f8a

Browse files
committed
@GitHK review: composed 'OEC:{traceback}-{timestamp}'
1 parent 200d9f8 commit df87f8a

File tree

3 files changed

+80
-27
lines changed

3 files changed

+80
-27
lines changed

packages/common-library/src/common_library/error_codes.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,30 @@
1010
import hashlib
1111
import re
1212
import traceback
13-
from typing import TYPE_CHECKING, Annotated
13+
from datetime import datetime
14+
from typing import Annotated, Final, TypeAlias
1415

16+
import arrow
1517
from pydantic import StringConstraints, TypeAdapter
1618

17-
_LABEL = "OEC:{}"
18-
_PATTERN = r"OEC:[a-zA-Z0-9]+"
19-
20-
if TYPE_CHECKING:
21-
ErrorCodeStr = str
22-
else:
23-
ErrorCodeStr = Annotated[
24-
str, StringConstraints(strip_whitespace=True, pattern=_PATTERN)
25-
]
19+
_LABEL = "OEC:{fingerprint}-{timestamp}"
2620

2721
_LEN = 12 # chars (~48 bits)
22+
_NAMED_PATTERN = re.compile(
23+
r"OEC:(?P<fingerprint>[a-fA-F0-9]{12})-(?P<timestamp>\d{13,14})"
24+
# NOTE: timestamp limits: 13 digits (from 2001), 14 digits (good for ~500+ years)
25+
)
26+
_PATTERN = re.compile(r"OEC:[a-fA-F0-9]{12}-\d{13,14}")
27+
2828

29+
ErrorCodeStr: TypeAlias = Annotated[
30+
str, StringConstraints(strip_whitespace=True, pattern=_NAMED_PATTERN)
31+
]
2932

30-
def _generate_error_fingerprint(exc: BaseException) -> str:
33+
34+
def _create_fingerprint(exc: BaseException) -> str:
3135
"""
32-
Unique error fingerprint for deduplication purposes
36+
Unique error fingerprint of the **traceback** for deduplication purposes
3337
"""
3438
tb = traceback.extract_tb(exc.__traceback__)
3539
frame_sigs = [f"{frame.name}:{frame.lineno}" for frame in tb]
@@ -38,11 +42,43 @@ def _generate_error_fingerprint(exc: BaseException) -> str:
3842
return hashlib.sha256(fingerprint.encode()).hexdigest()[:_LEN]
3943

4044

45+
_MILISECONDS: Final[int] = 1000
46+
47+
48+
def _create_timestamp() -> int:
49+
"""Timestamp as milliseconds since epoch
50+
NOTE: this reduces the precission to milliseconds but it is good enough for our purpose
51+
"""
52+
ts = arrow.utcnow().float_timestamp * _MILISECONDS
53+
return int(ts)
54+
55+
4156
def create_error_code(exception: BaseException) -> ErrorCodeStr:
57+
"""
58+
Generates a unique error code for the given exception.
59+
60+
The error code follows the format: `OEC:{traceback}-{timestamp}`.
61+
This code is intended to be shared with the front-end as a `SupportID`
62+
for debugging and support purposes.
63+
"""
4264
return TypeAdapter(ErrorCodeStr).validate_python(
43-
_LABEL.format(_generate_error_fingerprint(exception))
65+
_LABEL.format(
66+
fingerprint=_create_fingerprint(exception),
67+
timestamp=_create_timestamp(),
68+
)
4469
)
4570

4671

47-
def parse_error_code(obj) -> set[ErrorCodeStr]:
48-
return set(re.findall(_PATTERN, f"{obj}"))
72+
def parse_error_codes(obj) -> list[ErrorCodeStr]:
73+
return TypeAdapter(list[ErrorCodeStr]).validate_python(_PATTERN.findall(f"{obj}"))
74+
75+
76+
def parse_error_code_parts(oec: ErrorCodeStr) -> tuple[str, datetime]:
77+
"""Returns traceback-fingerprint and timestamp from `OEC:{traceback}-{timestamp}`"""
78+
match = _NAMED_PATTERN.match(oec)
79+
if not match:
80+
msg = f"Invalid error code format: {oec}"
81+
raise ValueError(msg)
82+
fingerprint = match.group("fingerprint")
83+
timestamp = arrow.get(int(match.group("timestamp")) / _MILISECONDS).datetime
84+
return fingerprint, timestamp

packages/common-library/tests/test_error_codes.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
# pylint: disable=unused-variable
55

66
import logging
7+
import time
78

89
import pytest
9-
from common_library.error_codes import create_error_code, parse_error_code
10+
from common_library.error_codes import (
11+
create_error_code,
12+
parse_error_code_parts,
13+
parse_error_codes,
14+
)
1015

1116
logger = logging.getLogger(__name__)
1217

@@ -31,18 +36,31 @@ def test_exception_fingerprint_consistency():
3136
# emulates different runs of the same function (e.g. different sessions)
3237
try:
3338
_level_one(v) # same even if different value!
39+
time.sleep(0.1)
3440
except Exception as err:
3541
error_code = create_error_code(err)
3642
error_codes.append(error_code)
3743

38-
assert error_codes == [error_codes[0]] * len(error_codes)
44+
fingerprints, timestamps = list(
45+
zip(
46+
*[parse_error_code_parts(error_code) for error_code in error_codes],
47+
strict=True,
48+
)
49+
)
50+
51+
assert fingerprints[0] == fingerprints[1]
52+
assert timestamps[0] < timestamps[1]
3953

54+
time.sleep(0.1)
4055
try:
4156
# Same function but different location
4257
_level_one(0)
4358
except Exception as e2:
4459
error_code_2 = create_error_code(e2)
45-
assert error_code_2 != error_code[0]
60+
fingerprint_2, timestamp_2 = parse_error_code_parts(error_code_2)
61+
62+
assert fingerprints[0] != fingerprint_2
63+
assert timestamps[1] < timestamp_2
4664

4765

4866
def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
@@ -66,11 +84,11 @@ def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
6684
logger.exception("Fake Unexpected error", extra={"error_code": error_code})
6785

6886
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
69-
assert parse_error_code(
87+
assert parse_error_codes(
7088
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
71-
) == {
89+
) == [
7290
error_code,
73-
}
91+
]
7492

7593
assert caplog.records[0].error_code == error_code
7694
assert caplog.records[0]
@@ -82,6 +100,6 @@ def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
82100
f"This is a user-friendly message to inform about an error. [{error_code}]"
83101
)
84102

85-
assert parse_error_code(user_message) == {
103+
assert parse_error_codes(user_message) == [
86104
error_code,
87-
}
105+
]

packages/service-library/tests/aiohttp/test_rest_middlewares.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414
from aiohttp import web
1515
from aiohttp.test_utils import TestClient
16-
from common_library.error_codes import parse_error_code
16+
from common_library.error_codes import parse_error_codes
1717
from common_library.json_serialization import json_dumps
1818
from servicelib.aiohttp import status
1919
from servicelib.aiohttp.rest_middlewares import (
@@ -30,8 +30,7 @@ class Data:
3030
y: str = "foo"
3131

3232

33-
class SomeUnexpectedError(Exception):
34-
...
33+
class SomeUnexpectedError(Exception): ...
3534

3635

3736
class Handlers:
@@ -237,7 +236,7 @@ async def test_raised_unhandled_exception(
237236

238237
# user friendly message with OEC reference
239238
assert "OEC" in error["message"]
240-
parsed_oec = parse_error_code(error["message"]).pop()
239+
parsed_oec = parse_error_codes(error["message"]).pop()
241240
assert (
242241
_FMSG_INTERNAL_ERROR_USER_FRIENDLY_WITH_OEC.format(error_code=parsed_oec)
243242
== error["message"]

0 commit comments

Comments
 (0)