Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 6 additions & 1 deletion localstack-core/localstack/aws/protocol/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,8 +1124,13 @@ def _parse_type_tag(self, stream: io.BufferedReader, additional_info: int):
raise ProtocolParserError(f"Found CBOR tag not supported by botocore: {tag}")

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

Expand Down
38 changes: 0 additions & 38 deletions localstack-core/localstack/aws/spec-patches.json
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Nice to see the patches introduced with #13103 are removed here since the official specs in botocore already contain these changes now! 💯 🧹

Original file line number Diff line number Diff line change
Expand Up @@ -1372,43 +1372,5 @@
"path": "/operations/CreateApiMapping/http/responseCode",
"value": 200
}
],
"cloudwatch/2010-08-01/service-2": [
{
"op": "add",
"path": "/metadata/awsQueryCompatible",
"value": {}
},
{
"op": "add",
"path": "/metadata/jsonVersion",
"value": "1.0"
},
{
"op": "add",
"path": "/metadata/targetPrefix",
"value": "GraniteServiceVersion20100801"
},
{
"op": "replace",
"path": "/metadata/protocol",
"value": "smithy-rpc-v2-cbor"
},
{
"op": "replace",
"path": "/metadata/protocols",
"value": [
"smithy-rpc-v2-cbor",
"json",
"query"
]
},
{
"op": "add",
"path": "/shapes/ConflictException/error",
"value": {
"httpStatusCode": 409
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ def get_metric_data(
# Paginate
timestamp_value_dicts = [
{
"Timestamp": timestamp,
"Timestamp": datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC),
"Value": float(value),
}
for timestamp, value in zip(timestamps, values, strict=False)
Expand Down
31 changes: 31 additions & 0 deletions tests/aws/services/cloudwatch/test_cloudwatch.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import gzip
import json
import logging
import re
import threading
import time
from datetime import UTC, datetime, timedelta, timezone
Expand Down Expand Up @@ -3026,6 +3027,36 @@ def _get_metric_data_sum():
)
snapshot.match("get-metric-data", response)

# we need special assertions for raw timestamp values, based on the protocol:
if protocol == "query":
timestamp = response["GetMetricDataResponse"]["GetMetricDataResult"][
"MetricDataResults"
]["member"][0]["Timestamps"]["member"]
assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", timestamp)

elif protocol == "json":
timestamp = response["MetricDataResults"][0]["Timestamps"][0]
assert isinstance(timestamp, float)
# assert this format: 1765977780.0
assert re.match(r"^\d{10}\.0", str(timestamp))
else:
timestamp = response["MetricDataResults"][0]["Timestamps"][0]
assert isinstance(timestamp, datetime)
assert timestamp.microsecond == 0
assert timestamp.year == now.year
assert now.day - 1 <= timestamp.day <= now.day + 1

# we need to decode more for CBOR, to verify we encode it the same way as AWS (datetime format + proper
# underlying format (float)
# See https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html#timestamp-type-serialization
# https://datatracker.ietf.org/doc/html/rfc8949.html#section-3.4
response_raw = http_client.post_raw(
operation="GetMetricData",
payload=get_metric_input,
)
# assert that the timestamp is encoded as a Tag (6 major type) with Double of length 8
assert b"Timestamps\x9f\xc1\xfbA" in response_raw.content

@markers.aws.validated
@pytest.mark.skipif(is_old_provider(), reason="Wrong behavior in v1 in SetAlarmState")
@pytest.mark.parametrize("protocol", ["json", "smithy-rpc-v2-cbor", "query"])
Expand Down
28 changes: 14 additions & 14 deletions tests/aws/services/cloudwatch/test_cloudwatch.validation.json
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
{
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[json]": {
"last_validated_date": "2025-10-06T14:45:54+00:00",
"last_validated_date": "2025-12-17T13:53:06+00:00",
"durations_in_seconds": {
"setup": 0.82,
"call": 2.64,
"teardown": 0.01,
"total": 3.47
"setup": 0.56,
"call": 3.57,
"teardown": 0.0,
"total": 4.13
}
},
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[query]": {
"last_validated_date": "2025-10-06T14:45:59+00:00",
"last_validated_date": "2025-12-17T13:53:13+00:00",
"durations_in_seconds": {
"setup": 0.01,
"call": 2.39,
"teardown": 0.02,
"total": 2.42
"setup": 0.0,
"call": 3.0,
"teardown": 0.0,
"total": 3.0
}
},
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_basic_operations_multiple_protocols[smithy-rpc-v2-cbor]": {
"last_validated_date": "2025-10-06T14:45:56+00:00",
"last_validated_date": "2025-12-17T13:53:10+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 2.4,
"teardown": 0.02,
"total": 2.42
"call": 3.15,
"teardown": 0.0,
"total": 3.15
}
},
"tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudWatchMultiProtocol::test_exception_serializing_with_no_shape_in_spec[json]": {
Expand Down
8 changes: 5 additions & 3 deletions tests/aws/services/cloudwatch/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import xmltodict
from botocore.auth import SigV4Auth
from botocore.serialize import create_serializer
from cbor2._decoder import loads as cbor2_loads

# import the unpatched cbor2 on purpose to avoid being polluted by Kinesis-only patches
from cbor2 import loads as cbor2_loads
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Whoof, great catch!

from requests import Response

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

@property
Expand Down
18 changes: 17 additions & 1 deletion tests/unit/aws/protocol/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,7 +1452,6 @@ def test_restxml_ignores_get_body():
def test_smithy_rpc_v2_cbor():
# we are using a service that LocalStack does not implement yet because it implements `smithy-rpc-v2-cbor`
# we can replace this service by CloudWatch once it has support in Botocore
# TODO: test timestamp parsing
# example taken from:
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/arc-region-switch/client/create_plan.html

Expand Down Expand Up @@ -1525,6 +1524,23 @@ def test_smithy_rpc_v2_cbor():
)


def test_rpc_v2_cbor_timestamp_parsing():
# This is a real request from the Java SDK v2
# It does not encode Timestamps like Botocore: it encodes them as Double of Length 8 (botocore uses Integer)
request = HttpRequest(
method="POST",
path="/v1/service/GraniteServiceVersion20100801/operation/PutMetricData",
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",
)
parser = create_parser(load_service("cloudwatch"), protocol="smithy-rpc-v2-cbor")
parsed_operation_model, parsed_request = parser.parse(request)
timestamp = parsed_request["MetricData"][0]["Timestamp"]
assert isinstance(timestamp, datetime)
assert timestamp.microsecond == 135000
assert timestamp.minute == 38
assert timestamp.tzinfo == UTC


@pytest.mark.parametrize("protocol", ("json", "smithy-rpc-v2-cbor"))
def test_protocol_selection(protocol):
# we are using a service that LocalStack does not implement yet because it implements `smithy-rpc-v2-cbor`
Expand Down
Loading