Repository: localstack/localstack

Issue:
bug: Certain records cause `localstack.aws.protocol.parser.ProtocolParserError` but work as expected with production AWS Kinesis
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

I'm using Debezium for writing changes to a Postgres database to Kinesis. Certain calls to put object succeed using production Kinesis but fail using localstack.

One such record produces the following exception logs:
```
Traceback (most recent call last):
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 264, in _parse_shape
    return handler(request, shape, payload, uri_params) if payload is not None else None
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 122, in _get_text_content
    return func(self, request, shape, text, uri_params)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 295, in _parse_blob
    return base64.b64decode(node)
  File "/usr/local/lib/python3.10/base64.py", line 87, in b64decode
    return binascii.a2b_base64(s)
binascii.Error: Incorrect padding

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/opt/code/localstack/localstack/aws/chain.py", line 57, in handle
    handler(self, self.context, response)
  File "/opt/code/localstack/localstack/aws/handlers/service.py", line 62, in __call__
    return self.parse_and_enrich(context)
  File "/opt/code/localstack/localstack/aws/handlers/service.py", line 75, in parse_and_enrich
    operation, instance = parser.parse(context.request)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 174, in wrapper
    return func(*args, **kwargs)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 914, in parse
    final_parsed = self._do_parse(request, shape, uri_params)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 926, in _do_parse
    parsed = self._handle_json_body(request, shape, uri_params)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 939, in _handle_json_body
    return self._parse_shape(request, shape, parsed_json, uri_params)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 264, in _parse_shape
    return handler(request, shape, payload, uri_params) if payload is not None else None
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 849, in _parse_structure
    parsed = self._parse_shape(request, member_shape, raw_value, uri_params)
  File "/opt/code/localstack/localstack/aws/protocol/parser.py", line 266, in _parse_shape
    raise ProtocolParserError(
localstack.aws.protocol.parser.ProtocolParserError: Invalid type when parsing Data: 'b'{"before":null,"after":{"id":1,"account_id":1,"primary_watchlist_id":null,"primary_searchlist_id":null},"source":{"version":"1.9.5.Final","connector":"postgresql","name":"dbsync","ts_ms":1658352377055,"snapshot":"true","db":"mydb","sequence":"[null,\\"39039528\\"]","schema":"public","table":"account_settings","txId":723,"lsn":39039528,"xmin":null},"op":"r","ts_ms":1658352377055,"transaction":null}'' cannot be parsed to blob.
```

### Expected Behavior

The call to put object with the record described above should succeed.

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

I've started localstack with
```
docker compose up -d localstack
```
with the following entry in my compose file
```
localstack:
  container_name: localstack # required to run correctly
  image: localstack/localstack
  ports:
    - "4566:4566"
    - "4510-4559:4510-4559"
  environment:
    DEBUG: 1
    DOCKER_HOST: unix:///var/run/docker.sock
    KINESIS_INITIALIZE_STREAMS: dbsync:1,dbsync.events:1
  volumes:
    - ./dbsync/localstack:/var/lib/localstack
    - /var/run/docker.sock:/var/run/docker.sock
```

### Environment

```markdown
- OS: MacOS Monterey (12.4)
- LocalStack: latest
```


### Anything else?

The code responsible for making put object calls can be [found here](https://github.com/debezium/debezium/blob/main/debezium-server/debezium-server-kinesis/src/main/java/io/debezium/server/kinesis/KinesisChangeConsumer.java#L108-L113) from the Debezium codebase.
```
    @Override
    public void handleBatch(List<ChangeEvent<Object, Object>> records, RecordCommitter<ChangeEvent<Object, Object>> committer)
            throws InterruptedException {
        for (ChangeEvent<Object, Object> record : records) {
            LOGGER.trace("Received event '{}'", record);
            Object rv = record.value();
            if (rv == null) {
                rv = "";
            }

            final PutRecordRequest putRecord = PutRecordRequest.builder()
                    .partitionKey((record.key() != null) ? getString(record.key()) : nullKey)
                    .streamName(streamNameMapper.map(record.destination()))
                    .data(SdkBytes.fromByteArray(getBytes(rv)))
                    .build();
            client.putRecord(putRecord);
            committer.markProcessed(record);
        }
        committer.markBatchFinished();
    }
```


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [developer guide](https://docs.localstack.cloud/developer-guide/).

Resolving patch:
diff --git a/localstack/aws/client.py b/localstack/aws/client.py
--- a/localstack/aws/client.py
+++ b/localstack/aws/client.py
@@ -1,14 +1,16 @@
 """Utils to process AWS requests as a client."""
 import io
 import logging
+from datetime import datetime
 from typing import Dict, Iterable, Optional
 
 from botocore.model import OperationModel
-from botocore.parsers import ResponseParser
-from botocore.parsers import create_parser as create_response_parser
+from botocore.parsers import ResponseParser, ResponseParserFactory
 from werkzeug import Response
 
 from localstack.aws.api import CommonServiceException, ServiceException, ServiceResponse
+from localstack.runtime import hooks
+from localstack.utils.patch import patch
 
 LOG = logging.getLogger(__name__)
 
@@ -85,6 +87,38 @@ def _add_modeled_error_fields(
     parsed_response.update(modeled_parse)
 
 
+def _cbor_timestamp_parser(value):
+    return datetime.fromtimestamp(value / 1000)
+
+
+def _cbor_blob_parser(value):
+    return bytes(value)
+
+
+@hooks.on_infra_start()
+def _patch_botocore_json_parser():
+    from botocore.parsers import BaseJSONParser
+
+    @patch(BaseJSONParser._parse_body_as_json)
+    def _parse_body_as_json(fn, self, body_contents):
+        """
+        botocore does not support CBOR encoded response parsing. Since we use the botocore parsers
+        to parse responses from external backends (like kinesalite), we need to patch botocore to
+        try CBOR decoding in case the JSON decoding fails.
+        """
+        try:
+            return fn(self, body_contents)
+        except UnicodeDecodeError as json_exception:
+            import cbor2
+
+            try:
+                LOG.debug("botocore failed decoding JSON. Trying to decode as CBOR.")
+                return cbor2.loads(body_contents)
+            except Exception as cbor_exception:
+                LOG.debug("CBOR fallback decoding failed.")
+                raise cbor_exception from json_exception
+
+
 def parse_response(
     operation: OperationModel, response: Response, include_response_metadata: bool = True
 ) -> ServiceResponse:
@@ -117,7 +151,14 @@ def parse_response(
     else:
         response_dict["body"] = response.data
 
-    parser = create_response_parser(operation.service_model.protocol)
+    factory = ResponseParserFactory()
+    if response.content_type and response.content_type.startswith("application/x-amz-cbor"):
+        # botocore cannot handle CBOR encoded responses (because it never sends them), we need to modify the parser
+        factory.set_parser_defaults(
+            timestamp_parser=_cbor_timestamp_parser, blob_parser=_cbor_blob_parser
+        )
+
+    parser = factory.create_parser(operation.service_model.protocol)
     parsed_response = parser.parse(response_dict, operation.output_shape)
 
     if response.status_code >= 301:
diff --git a/localstack/aws/protocol/parser.py b/localstack/aws/protocol/parser.py
--- a/localstack/aws/protocol/parser.py
+++ b/localstack/aws/protocol/parser.py
@@ -890,6 +890,15 @@ def _parse_boolean(
     ) -> bool:
         return super()._noop_parser(request, shape, node, uri_params)
 
+    def _parse_blob(
+        self, request: HttpRequest, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None
+    ) -> bytes:
+        if isinstance(node, bytes) and request.mimetype.startswith("application/x-amz-cbor"):
+            # CBOR does not base64 encode binary data
+            return bytes(node)
+        else:
+            return super()._parse_blob(request, shape, node, uri_params)
+
 
 class JSONRequestParser(BaseJSONRequestParser):
     """


Test patch:
diff --git a/tests/unit/aws/protocol/test_parser.py b/tests/unit/aws/protocol/test_parser.py
--- a/tests/unit/aws/protocol/test_parser.py
+++ b/tests/unit/aws/protocol/test_parser.py
@@ -553,6 +553,58 @@ def test_json_parser_cognito_with_botocore():
     )
 
 
+def test_json_cbor_blob_parsing():
+    serialized_request = {
+        "url_path": "/",
+        "query_string": "",
+        "method": "POST",
+        "headers": {
+            "Host": "localhost:4566",
+            "amz-sdk-invocation-id": "d77968c6-b536-155d-7228-d4dfe6372154",
+            "amz-sdk-request": "attempt=1; max=3",
+            "Content-Length": "103",
+            "Content-Type": "application/x-amz-cbor-1.1",
+            "X-Amz-Date": "20220721T081553Z",
+            "X-Amz-Target": "Kinesis_20131202.PutRecord",
+            "x-localstack-tgt-api": "kinesis",
+        },
+        "body": b"\xbfjStreamNamedtestdDataMhello, world!lPartitionKeylpartitionkey\xff",
+        "url": "/",
+        "context": {},
+    }
+
+    prepare_request_dict(serialized_request, "")
+    split_url = urlsplit(serialized_request.get("url"))
+    path = split_url.path
+    query_string = split_url.query
+
+    # Use our parser to parse the serialized body
+    # Load the appropriate service
+    service = load_service("kinesis")
+    operation_model = service.operation_model("PutRecord")
+    parser = create_parser(service)
+    parsed_operation_model, parsed_request = parser.parse(
+        HttpRequest(
+            method=serialized_request.get("method") or "GET",
+            path=unquote(path),
+            query_string=to_str(query_string),
+            headers=serialized_request.get("headers"),
+            body=serialized_request["body"],
+            raw_path=path,
+        )
+    )
+
+    # Check if the determined operation_model is correct
+    assert parsed_operation_model == operation_model
+
+    assert "Data" in parsed_request
+    assert parsed_request["Data"] == b"hello, world!"
+    assert "StreamName" in parsed_request
+    assert parsed_request["StreamName"] == "test"
+    assert "PartitionKey" in parsed_request
+    assert parsed_request["PartitionKey"] == "partitionkey"
+
+
 def test_restjson_parser_xray_with_botocore():
     _botocore_parser_integration_test(
         service="xray",