Skip to content

Commit 6e67a44

Browse files
authored
🐛 fixes unhandled exception when parsing an invalid string (ITISFoundation#2608)
An unhandled exception is causing the director service to respond with 500 every time is requested to list running services services. The exception displayed ValueError: unconverted data remains: Z and was raised while parsing an invalid string into a datetime. utils. parse_as_datetime solved that.
1 parent 0446da5 commit 6e67a44

File tree

3 files changed

+78
-20
lines changed

3 files changed

+78
-20
lines changed

services/director/src/simcore_service_director/producer.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,13 @@
1313
import aiodocker
1414
import tenacity
1515
from aiohttp import ClientConnectionError, ClientSession, web
16-
17-
from servicelib.async_utils import (
16+
from servicelib.async_utils import ( # pylint: disable=no-name-in-module
1817
run_sequentially_in_context,
19-
) # pylint: disable=no-name-in-module
20-
from servicelib.monitor_services import (
18+
)
19+
from servicelib.monitor_services import ( # pylint: disable=no-name-in-module
2120
service_started,
2221
service_stopped,
23-
) # pylint: disable=no-name-in-module
22+
)
2423

2524
from . import config, docker_utils, exceptions, registry_proxy
2625
from .config import (
@@ -30,6 +29,7 @@
3029
)
3130
from .services_common import ServicesCommonSettings
3231
from .system_utils import get_system_extra_hosts_raw
32+
from .utils import parse_as_datetime
3333

3434
log = logging.getLogger(__name__)
3535

@@ -503,9 +503,6 @@ async def _remove_overlay_network_of_swarm(
503503
) from err
504504

505505

506-
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
507-
508-
509506
async def _get_service_state(
510507
client: aiodocker.docker.Docker, service: Dict
511508
) -> Tuple[ServiceState, str]:
@@ -532,18 +529,7 @@ async def _wait_for_tasks(tasks):
532529
last_task = sorted(tasks, key=lambda task: task["UpdatedAt"])[-1]
533530
task_state = last_task["Status"]["State"]
534531

535-
def _to_datetime(datetime_str: str) -> datetime:
536-
# datetime_str is typically '2020-10-09T12:28:14.771034099Z'
537-
# - The T separates the date portion from the time-of-day portion
538-
# - The Z on the end means UTC, that is, an offset-from-UTC
539-
# The 099 before the Z is not clear, therefore we will truncate the last part
540-
N = len("2020-10-09T12:28:14.7710")
541-
if len(datetime_str) > N:
542-
datetime_str = datetime_str[:N]
543-
return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S.%f")
544-
545-
task_state_update_time = _to_datetime(last_task["Status"]["Timestamp"])
546-
log.debug("%s %s: time %s", service["ID"], task_state, task_state_update_time)
532+
log.debug("%s %s", service["ID"], task_state)
547533

548534
last_task_state = ServiceState.STARTING # default
549535
last_task_error_msg = (
@@ -573,14 +559,20 @@ def _to_datetime(datetime_str: str) -> datetime:
573559
last_task_state = ServiceState.STARTING
574560
elif task_state in ("running"):
575561
now = datetime.utcnow()
562+
# NOTE: task_state_update_time is only used to discrimitate between 'starting' and 'running'
563+
task_state_update_time = parse_as_datetime(
564+
last_task["Status"]["Timestamp"], default=now
565+
)
576566
time_since_running = now - task_state_update_time
567+
577568
log.debug("Now is %s, time since running mode is %s", now, time_since_running)
578569
if time_since_running > timedelta(
579570
seconds=config.DIRECTOR_SERVICES_STATE_MONITOR_S
580571
):
581572
last_task_state = ServiceState.RUNNING
582573
else:
583574
last_task_state = ServiceState.STARTING
575+
584576
elif task_state in ("complete", "shutdown"):
585577
last_task_state = ServiceState.COMPLETE
586578
log.debug("service running state is %s", last_task_state)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import logging
2+
from datetime import datetime
3+
from typing import Optional
4+
5+
log = logging.getLogger(__name__)
6+
7+
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
8+
_MAXLEN = len("2020-10-09T12:28:14.7710")
9+
10+
11+
def parse_as_datetime(timestr: str, *, default: Optional[datetime] = None) -> datetime:
12+
"""
13+
default: if parsing is not possible, it returs default
14+
"""
15+
# datetime_str is typically '2020-10-09T12:28:14.771034099Z'
16+
# - The T separates the date portion from the time-of-day portion
17+
# - The Z on the end means UTC, that is, an offset-from-UTC
18+
# The 099 before the Z is not clear, therefore we will truncate the last part
19+
20+
try:
21+
timestr = timestr.strip("Z ")[:_MAXLEN]
22+
dt = datetime.strptime(timestr, DATETIME_FORMAT)
23+
return dt
24+
except ValueError as err:
25+
log.debug("Failed to parse %s: %s", timestr, err)
26+
if default is not None:
27+
return default
28+
raise
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from datetime import datetime
2+
3+
import pytest
4+
from simcore_service_director.utils import parse_as_datetime
5+
6+
7+
@pytest.mark.parametrize(
8+
"timestr",
9+
(
10+
# Samples taken from https://docs.docker.com/engine/reference/commandline/service_inspect/
11+
"2020-10-09T18:44:02.558012087Z",
12+
"2020-10-09T12:28:14.771034099Z",
13+
"2020-10-09T12:28:14.7710",
14+
# found cases with spaces
15+
"2020-10-09T12:28:14.77 Z",
16+
" 2020-10-09T12:28:14.77 ",
17+
),
18+
)
19+
def test_parse_valid_time_strings(timestr):
20+
21+
dt = parse_as_datetime(timestr)
22+
assert isinstance(dt, datetime)
23+
assert dt.year == 2020
24+
assert dt.month == 10
25+
assert dt.day == 9
26+
27+
28+
def test_parse_invalid_timestr():
29+
now = datetime.utcnow()
30+
invalid_timestr = "2020-10-09T12:28"
31+
32+
# w/ default, it should NOT raise
33+
dt = parse_as_datetime(invalid_timestr, default=now)
34+
assert dt == now
35+
36+
# w/o default
37+
with pytest.raises(ValueError):
38+
parse_as_datetime(invalid_timestr)

0 commit comments

Comments
 (0)