Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit 033f449

Browse files
authored
Fix RPC v2 CBOR timestamp parsing for float (#13541)
1 parent 9d08473 commit 033f449

File tree

7 files changed

+75
-58
lines changed

7 files changed

+75
-58
lines changed

localstack-core/localstack/aws/protocol/parser.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1124,8 +1124,13 @@ def _parse_type_tag(self, stream: io.BufferedReader, additional_info: int):
11241124
raise ProtocolParserError(f"Found CBOR tag not supported by botocore: {tag}")
11251125

11261126
def _parse_type_datetime(self, value: int | float) -> datetime.datetime:
1127+
# CBOR overrides any timestamp format defined in the spec:
1128+
# https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html#timestamp-type-serialization
1129+
# > This protocol uses epoch-seconds, also known as Unix timestamps, with millisecond (1/1000th of a second)
1130+
# > resolution. The timestampFormat MUST NOT be respected to customize timestamp serialization.
11271131
if isinstance(value, (int, float)):
1128-
return self._convert_str_to_timestamp(str(value))
1132+
milli_precision_ts = int(value * 1000) / 1000
1133+
return datetime.datetime.fromtimestamp(milli_precision_ts, tz=datetime.UTC)
11291134
else:
11301135
raise ProtocolParserError(f"Unable to parse datetime value: {value}")
11311136

localstack-core/localstack/aws/spec-patches.json

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,43 +1372,5 @@
13721372
"path": "/operations/CreateApiMapping/http/responseCode",
13731373
"value": 200
13741374
}
1375-
],
1376-
"cloudwatch/2010-08-01/service-2": [
1377-
{
1378-
"op": "add",
1379-
"path": "/metadata/awsQueryCompatible",
1380-
"value": {}
1381-
},
1382-
{
1383-
"op": "add",
1384-
"path": "/metadata/jsonVersion",
1385-
"value": "1.0"
1386-
},
1387-
{
1388-
"op": "add",
1389-
"path": "/metadata/targetPrefix",
1390-
"value": "GraniteServiceVersion20100801"
1391-
},
1392-
{
1393-
"op": "replace",
1394-
"path": "/metadata/protocol",
1395-
"value": "smithy-rpc-v2-cbor"
1396-
},
1397-
{
1398-
"op": "replace",
1399-
"path": "/metadata/protocols",
1400-
"value": [
1401-
"smithy-rpc-v2-cbor",
1402-
"json",
1403-
"query"
1404-
]
1405-
},
1406-
{
1407-
"op": "add",
1408-
"path": "/shapes/ConflictException/error",
1409-
"value": {
1410-
"httpStatusCode": 409
1411-
}
1412-
}
14131375
]
14141376
}

localstack-core/localstack/services/cloudwatch/provider_v2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def get_metric_data(
277277
# Paginate
278278
timestamp_value_dicts = [
279279
{
280-
"Timestamp": timestamp,
280+
"Timestamp": datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC),
281281
"Value": float(value),
282282
}
283283
for timestamp, value in zip(timestamps, values, strict=False)

tests/aws/services/cloudwatch/test_cloudwatch.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import gzip
22
import json
33
import logging
4+
import re
45
import threading
56
import time
67
from datetime import UTC, datetime, timedelta, timezone
@@ -3026,6 +3027,36 @@ def _get_metric_data_sum():
30263027
)
30273028
snapshot.match("get-metric-data", response)
30283029

3030+
# we need special assertions for raw timestamp values, based on the protocol:
3031+
if protocol == "query":
3032+
timestamp = response["GetMetricDataResponse"]["GetMetricDataResult"][
3033+
"MetricDataResults"
3034+
]["member"][0]["Timestamps"]["member"]
3035+
assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", timestamp)
3036+
3037+
elif protocol == "json":
3038+
timestamp = response["MetricDataResults"][0]["Timestamps"][0]
3039+
assert isinstance(timestamp, float)
3040+
# assert this format: 1765977780.0
3041+
assert re.match(r"^\d{10}\.0", str(timestamp))
3042+
else:
3043+
timestamp = response["MetricDataResults"][0]["Timestamps"][0]
3044+
assert isinstance(timestamp, datetime)
3045+
assert timestamp.microsecond == 0
3046+
assert timestamp.year == now.year
3047+
assert now.day - 1 <= timestamp.day <= now.day + 1
3048+
3049+
# we need to decode more for CBOR, to verify we encode it the same way as AWS (datetime format + proper
3050+
# underlying format (float)
3051+
# See https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html#timestamp-type-serialization
3052+
# https://datatracker.ietf.org/doc/html/rfc8949.html#section-3.4
3053+
response_raw = http_client.post_raw(
3054+
operation="GetMetricData",
3055+
payload=get_metric_input,
3056+
)
3057+
# assert that the timestamp is encoded as a Tag (6 major type) with Double of length 8
3058+
assert b"Timestamps\x9f\xc1\xfbA" in response_raw.content
3059+
30293060
@markers.aws.validated
30303061
@pytest.mark.skipif(is_old_provider(), reason="Wrong behavior in v1 in SetAlarmState")
30313062
@pytest.mark.parametrize("protocol", ["json", "smithy-rpc-v2-cbor", "query"])

tests/aws/services/cloudwatch/test_cloudwatch.validation.json

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
{
22
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[json]": {
3-
"last_validated_date": "2025-10-06T14:45:54+00:00",
3+
"last_validated_date": "2025-12-17T13:53:06+00:00",
44
"durations_in_seconds": {
5-
"setup": 0.82,
6-
"call": 2.64,
7-
"teardown": 0.01,
8-
"total": 3.47
5+
"setup": 0.56,
6+
"call": 3.57,
7+
"teardown": 0.0,
8+
"total": 4.13
99
}
1010
},
1111
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[query]": {
12-
"last_validated_date": "2025-10-06T14:45:59+00:00",
12+
"last_validated_date": "2025-12-17T13:53:13+00:00",
1313
"durations_in_seconds": {
14-
"setup": 0.01,
15-
"call": 2.39,
16-
"teardown": 0.02,
17-
"total": 2.42
14+
"setup": 0.0,
15+
"call": 3.0,
16+
"teardown": 0.0,
17+
"total": 3.0
1818
}
1919
},
2020
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[smithy-rpc-v2-cbor]": {
21-
"last_validated_date": "2025-10-06T14:45:56+00:00",
21+
"last_validated_date": "2025-12-17T13:53:10+00:00",
2222
"durations_in_seconds": {
2323
"setup": 0.0,
24-
"call": 2.4,
25-
"teardown": 0.02,
26-
"total": 2.42
24+
"call": 3.15,
25+
"teardown": 0.0,
26+
"total": 3.15
2727
}
2828
},
2929
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_exception_serializing_with_no_shape_in_spec[json]": {

tests/aws/services/cloudwatch/utils.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import xmltodict
66
from botocore.auth import SigV4Auth
77
from botocore.serialize import create_serializer
8-
from cbor2._decoder import loads as cbor2_loads
8+
9+
# import the unpatched cbor2 on purpose to avoid being polluted by Kinesis-only patches
10+
from cbor2 import loads as cbor2_loads
911
from requests import Response
1012

1113
from localstack import constants
@@ -45,9 +47,9 @@ def _build_headers(self, operation: str, query_mode: bool = False) -> dict: ...
4547
def _serialize_body(self, body: dict, operation: str) -> str | bytes:
4648
# here we use the Botocore serializer directly, since it has some complex behavior,
4749
# and we know CloudWatch supports it by default
48-
query_serializer = create_serializer(self.protocol)
50+
protocol_serializer = create_serializer(self.protocol)
4951
operation_model = self.service_model.operation_model(operation)
50-
request = query_serializer.serialize_to_request(body, operation_model)
52+
request = protocol_serializer.serialize_to_request(body, operation_model)
5153
return request["body"]
5254

5355
@property

tests/unit/aws/protocol/test_parser.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1452,7 +1452,6 @@ def test_restxml_ignores_get_body():
14521452
def test_smithy_rpc_v2_cbor():
14531453
# we are using a service that LocalStack does not implement yet because it implements `smithy-rpc-v2-cbor`
14541454
# we can replace this service by CloudWatch once it has support in Botocore
1455-
# TODO: test timestamp parsing
14561455
# example taken from:
14571456
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/arc-region-switch/client/create_plan.html
14581457

@@ -1525,6 +1524,24 @@ def test_smithy_rpc_v2_cbor():
15251524
)
15261525

15271526

1527+
def test_rpc_v2_cbor_timestamp_parsing():
1528+
# This is a real request from the Java SDK v2
1529+
# It does not encode Timestamps like Botocore: it encodes them as Double of Length 8 (botocore uses Integer)
1530+
request = HttpRequest(
1531+
method="POST",
1532+
path="/v1/service/GraniteServiceVersion20100801/operation/PutMetricData",
1533+
body=b"\xbfiNamespacelSITE/TRAFFICjMetricData\x81\xbfjMetricNamemPAGES_VISITEDjDimensions\x81\xbfdNamelUNIQUE_PAGESeValuedURLS\xffiTimestamp\xc1\xfbA\xdaP\xaf+\x88\xa3\xd7eValue\xfb?\xf3\xbfg\xf4\xdb\xdf\x8fdUnitdNone\xff\xff",
1534+
)
1535+
parser = create_parser(load_service("cloudwatch"), protocol="smithy-rpc-v2-cbor")
1536+
parsed_operation_model, parsed_request = parser.parse(request)
1537+
timestamp = parsed_request["MetricData"][0]["Timestamp"]
1538+
assert isinstance(timestamp, datetime)
1539+
assert timestamp.microsecond == 135000
1540+
assert timestamp.second == 38
1541+
assert timestamp.minute == 22
1542+
assert timestamp.tzinfo == UTC
1543+
1544+
15281545
@pytest.mark.parametrize("protocol", ("json", "smithy-rpc-v2-cbor"))
15291546
def test_protocol_selection(protocol):
15301547
# we are using a service that LocalStack does not implement yet because it implements `smithy-rpc-v2-cbor`

0 commit comments

Comments
 (0)