Skip to content

Commit 2f2cbb6

Browse files
committed
merge master into introduce-links-for-webserver-async-jobs
2 parents c4bc27c + 45ffe4e commit 2f2cbb6

File tree

27 files changed

+657
-342
lines changed

27 files changed

+657
-342
lines changed

api/specs/web-server/_auth.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@
4747
operation_id="request_product_account",
4848
status_code=status.HTTP_204_NO_CONTENT,
4949
)
50-
async def request_product_account(_body: AccountRequestInfo):
51-
...
50+
async def request_product_account(_body: AccountRequestInfo): ...
5251

5352

5453
@router.post(
@@ -75,8 +74,7 @@ async def register(_body: RegisterBody):
7574
status_code=status.HTTP_200_OK,
7675
responses={status.HTTP_409_CONFLICT: {"model": EnvelopedError}},
7776
)
78-
async def unregister_account(_body: UnregisterCheck):
79-
...
77+
async def unregister_account(_body: UnregisterCheck): ...
8078

8179

8280
@router.post(
@@ -171,26 +169,24 @@ async def check_auth():
171169
@router.post(
172170
"/auth/reset-password",
173171
response_model=Envelope[Log],
174-
operation_id="auth_reset_password",
172+
operation_id="initiate_reset_password",
175173
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"model": EnvelopedError}},
176174
)
177-
async def reset_password(_body: ResetPasswordBody):
178-
"""a non logged-in user requests a password reset"""
175+
async def initiate_reset_password(_body: ResetPasswordBody): ...
179176

180177

181178
@router.post(
182179
"/auth/reset-password/{code}",
183180
response_model=Envelope[Log],
184-
operation_id="auth_reset_password_allowed",
181+
operation_id="complete_reset_password",
185182
responses={
186183
status.HTTP_401_UNAUTHORIZED: {
187184
"model": EnvelopedError,
188-
"description": "unauthorized reset due to invalid token code",
185+
"description": "Invalid token code",
189186
}
190187
},
191188
)
192-
async def reset_password_allowed(code: str, _body: ResetPasswordConfirmation):
193-
"""changes password using a token code without being logged in"""
189+
async def complete_reset_password(code: str, _body: ResetPasswordConfirmation): ...
194190

195191

196192
@router.post(
@@ -268,5 +264,4 @@ async def email_confirmation(code: str):
268264
status_code=status.HTTP_200_OK,
269265
responses={status.HTTP_200_OK: {"content": {"image/png": {}}}},
270266
)
271-
async def request_captcha():
272-
...
267+
async def request_captcha(): ...
Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" osparc ERROR CODES (OEC)
1+
"""osparc ERROR CODES (OEC)
22
Unique identifier of an exception instance
33
Intended to report a user about unexpected errors.
44
Unexpected exceptions can be traced by matching the
@@ -7,25 +7,79 @@
77
SEE test_error_codes for some use cases
88
"""
99

10+
import hashlib
1011
import re
11-
from typing import TYPE_CHECKING, Annotated
12+
import traceback
13+
from datetime import UTC, datetime
14+
from typing import Annotated, Final, TypeAlias
1215

1316
from pydantic import StringConstraints, TypeAdapter
1417

15-
_LABEL = "OEC:{}"
16-
_PATTERN = r"OEC:\d+"
18+
_LABEL = "OEC:{fingerprint}-{timestamp}"
1719

18-
if TYPE_CHECKING:
19-
ErrorCodeStr = str
20-
else:
21-
ErrorCodeStr = Annotated[
22-
str, StringConstraints(strip_whitespace=True, pattern=_PATTERN)
23-
]
20+
_LEN = 12 # chars (~48 bits)
21+
_NAMED_PATTERN = re.compile(
22+
r"OEC:(?P<fingerprint>[a-fA-F0-9]{12})-(?P<timestamp>\d{13,14})"
23+
# NOTE: timestamp limits: 13 digits (from 2001), 14 digits (good for ~500+ years)
24+
)
25+
_PATTERN = re.compile(r"OEC:[a-fA-F0-9]{12}-\d{13,14}")
26+
27+
28+
ErrorCodeStr: TypeAlias = Annotated[
29+
str, StringConstraints(strip_whitespace=True, pattern=_NAMED_PATTERN)
30+
]
31+
32+
33+
def _create_fingerprint(exc: BaseException) -> str:
34+
"""
35+
Unique error fingerprint of the **traceback** for deduplication purposes
36+
"""
37+
tb = traceback.extract_tb(exc.__traceback__)
38+
frame_sigs = [f"{frame.name}:{frame.lineno}" for frame in tb]
39+
fingerprint = f"{type(exc).__name__}|" + "|".join(frame_sigs)
40+
# E.g. ZeroDivisionError|foo:23|main:10
41+
return hashlib.sha256(fingerprint.encode()).hexdigest()[:_LEN]
42+
43+
44+
_SECS_TO_MILISECS: Final[int] = 1000 # ms
45+
46+
47+
def _create_timestamp() -> int:
48+
"""Timestamp as milliseconds since epoch
49+
NOTE: this reduces the precission to milliseconds but it is good enough for our purpose
50+
"""
51+
ts = datetime.now(UTC).timestamp() * _SECS_TO_MILISECS
52+
return int(ts)
2453

2554

2655
def create_error_code(exception: BaseException) -> ErrorCodeStr:
27-
return TypeAdapter(ErrorCodeStr).validate_python(_LABEL.format(id(exception)))
56+
"""
57+
Generates a unique error code for the given exception.
58+
59+
The error code follows the format: `OEC:{traceback}-{timestamp}`.
60+
This code is intended to be shared with the front-end as a `SupportID`
61+
for debugging and support purposes.
62+
"""
63+
return TypeAdapter(ErrorCodeStr).validate_python(
64+
_LABEL.format(
65+
fingerprint=_create_fingerprint(exception),
66+
timestamp=_create_timestamp(),
67+
)
68+
)
69+
70+
71+
def parse_error_codes(obj) -> list[ErrorCodeStr]:
72+
return TypeAdapter(list[ErrorCodeStr]).validate_python(_PATTERN.findall(f"{obj}"))
2873

2974

30-
def parse_error_code(obj) -> set[ErrorCodeStr]:
31-
return set(re.findall(_PATTERN, f"{obj}"))
75+
def parse_error_code_parts(oec: ErrorCodeStr) -> tuple[str, datetime]:
76+
"""Returns traceback-fingerprint and timestamp from `OEC:{traceback}-{timestamp}`"""
77+
match = _NAMED_PATTERN.match(oec)
78+
if not match:
79+
msg = f"Invalid error code format: {oec}"
80+
raise ValueError(msg)
81+
fingerprint = match.group("fingerprint")
82+
timestamp = datetime.fromtimestamp(
83+
float(match.group("timestamp")) / _SECS_TO_MILISECS, tz=UTC
84+
)
85+
return fingerprint, timestamp

packages/common-library/tests/test_error_codes.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,68 @@
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

1318

14-
def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
15-
"""use case for error-codes"""
19+
def _level_three(v):
20+
msg = f"An error occurred in level three with {v}"
21+
raise RuntimeError(msg)
22+
23+
24+
def _level_two(v):
25+
_level_three(v)
26+
27+
28+
def _level_one(v=None):
29+
_level_two(v)
30+
31+
32+
def test_exception_fingerprint_consistency():
33+
error_codes = []
34+
35+
for v in range(2):
36+
# emulates different runs of the same function (e.g. different sessions)
37+
try:
38+
_level_one(v) # same even if different value!
39+
except Exception as err:
40+
time.sleep(1)
41+
error_code = create_error_code(err)
42+
error_codes.append(error_code)
43+
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]
53+
54+
try:
55+
# Same function but different location
56+
_level_one(0)
57+
except Exception as e2:
58+
time.sleep(1)
59+
error_code_2 = create_error_code(e2)
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
64+
65+
66+
def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
1667
with pytest.raises(RuntimeError) as exc_info:
17-
raise RuntimeError("Something unexpected went wrong")
68+
_level_one()
1869

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

3586
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
36-
assert parse_error_code(
87+
assert parse_error_codes(
3788
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
38-
) == {
89+
) == [
3990
error_code,
40-
}
91+
]
4192

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

52-
assert parse_error_code(user_message) == {
103+
assert parse_error_codes(user_message) == [
53104
error_code,
54-
}
105+
]

packages/models-library/src/models_library/rest_error.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
22
from typing import Annotated
33

4+
from common_library.basic_types import DEFAULT_FACTORY
45
from models_library.generics import Envelope
56
from pydantic import BaseModel, ConfigDict, Field
67

@@ -81,11 +82,11 @@ class ErrorGet(BaseModel):
8182
errors: Annotated[
8283
list[ErrorItemType],
8384
Field(deprecated=True, default_factory=list, json_schema_extra={"default": []}),
84-
]
85+
] = DEFAULT_FACTORY
8586
logs: Annotated[
8687
list[LogMessageType],
8788
Field(deprecated=True, default_factory=list, json_schema_extra={"default": []}),
88-
]
89+
] = DEFAULT_FACTORY
8990

9091
model_config = ConfigDict(
9192
populate_by_name=True,

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"]

services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/long_running_tasks.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,9 @@ async def _send_resource_tracking_stop(platform_status: SimcorePlatformStatus):
315315
# NOTE: https://github.com/ITISFoundation/osparc-simcore/issues/4952
316316
await _send_resource_tracking_stop(SimcorePlatformStatus.OK)
317317
raise
318-
319-
await ensure_read_permissions_on_user_service_data(mounted_volumes)
318+
finally:
319+
with log_context(_logger, logging.INFO, "ensure read permissions"):
320+
await ensure_read_permissions_on_user_service_data(mounted_volumes)
320321

321322
await _send_resource_tracking_stop(SimcorePlatformStatus.OK)
322323

services/static-webserver/client/source/class/osparc/data/Resources.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1490,7 +1490,10 @@ qx.Class.define("osparc.data.Resources", {
14901490
}
14911491

14921492
if ([404, 503].includes(status)) {
1493-
message += "<br>Please try again later and/or contact support";
1493+
// NOTE: a temporary solution to avoid duplicate information
1494+
if (!message.includes("contact") && !message.includes("try")) {
1495+
message += "<br>Please try again later and/or contact support";
1496+
}
14941497
}
14951498
const err = Error(message ? message : `Error while trying to fetch ${endpoint} ${resource}`);
14961499
if (status) {

services/web/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.61.0
1+
0.61.1

services/web/server/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.61.0
2+
current_version = 0.61.1
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.1.0
22
info:
33
title: simcore-service-webserver
44
description: Main service with an interface (http-API & websockets) to the web front-end
5-
version: 0.61.0
5+
version: 0.61.1
66
servers:
77
- url: ''
88
description: webserver
@@ -253,9 +253,8 @@ paths:
253253
post:
254254
tags:
255255
- auth
256-
summary: Reset Password
257-
description: a non logged-in user requests a password reset
258-
operationId: auth_reset_password
256+
summary: Initiate Reset Password
257+
operationId: initiate_reset_password
259258
requestBody:
260259
content:
261260
application/json:
@@ -279,9 +278,8 @@ paths:
279278
post:
280279
tags:
281280
- auth
282-
summary: Reset Password Allowed
283-
description: changes password using a token code without being logged in
284-
operationId: auth_reset_password_allowed
281+
summary: Complete Reset Password
282+
operationId: complete_reset_password
285283
parameters:
286284
- name: code
287285
in: path
@@ -303,7 +301,7 @@ paths:
303301
schema:
304302
$ref: '#/components/schemas/Envelope_Log_'
305303
'401':
306-
description: unauthorized reset due to invalid token code
304+
description: Invalid token code
307305
content:
308306
application/json:
309307
schema:
@@ -14153,6 +14151,7 @@ components:
1415314151
properties:
1415414152
email:
1415514153
type: string
14154+
format: email
1415614155
title: Email
1415714156
additionalProperties: false
1415814157
type: object
@@ -15290,7 +15289,6 @@ components:
1529015289
- change_email_email.jinja2
1529115290
- new_2fa_code.jinja2
1529215291
- registration_email.jinja2
15293-
- reset_password_email_failed.jinja2
1529415292
- reset_password_email.jinja2
1529515293
- service_submission.jinja2
1529615294
title: Template Name

0 commit comments

Comments
 (0)