Repository: getmoto/moto

Issue:
missing ContentLength in s3.Object.get() response when requesting partial content
**Steps to reproduce:**
```python
from moto import mock_s3
import boto3

file_path = "..."
with mock_s3():
    bucket = "test"
    object_key = "key"
    s3 = boto3.resource("s3")
    s3.create_bucket(Bucket=bucket)
    with open(file_path, "rb") as file:
        s3.Object(bucket, object_key).put(Body=file)

    file = s3.Object(bucket, object_key)
    response = file.get(Range="bytes=0-1024")
    print(response["ContentLength"])
```
**Expected behavior:**
```bash
1025
```

**Actual behavior:**
```bash
KeyError: 'ContentLength'
```

**Additional information:**
This only happens when requesting partial content. Possibly other important response's headers are ignored as well since the `headers` argument is not being used within `_handle_range_header`, as seen [here](https://github.com/spulec/moto/blob/master/moto/s3/responses.py#L877).

**Versions**
- moto                    1.3.14
- boto                     2.49.0
- boto3                   1.13.3
- botocore              1.16.3


Discussion:
Greetings,

We're hitting this too, and it's breaking our tests. I see this has been open since May, and is labelled an "Enhancement." Is that just an oversight? Can we bump this up to "Bug?"


Resolving patch:
diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py
--- a/moto/s3/exceptions.py
+++ b/moto/s3/exceptions.py
@@ -14,6 +14,11 @@
 {% block extra %}<Condition>{{ condition }}</Condition>{% endblock %}
 """
 
+ERROR_WITH_RANGE = """{% extends 'single_error' %}
+{% block extra %}<ActualObjectSize>{{ actual_size }}</ActualObjectSize>
+<RangeRequested>{{ range_requested }}</RangeRequested>{% endblock %}
+"""
+
 
 class S3ClientError(RESTError):
     def __init__(self, *args, **kwargs):
@@ -404,3 +409,18 @@ def __init__(self, failed_condition, **kwargs):
             condition=failed_condition,
             **kwargs
         )
+
+
+class InvalidRange(S3ClientError):
+    code = 416
+
+    def __init__(self, range_requested, actual_size, **kwargs):
+        kwargs.setdefault("template", "range_error")
+        self.templates["range_error"] = ERROR_WITH_RANGE
+        super(InvalidRange, self).__init__(
+            "InvalidRange",
+            "The requested range is not satisfiable",
+            range_requested=range_requested,
+            actual_size=actual_size,
+            **kwargs
+        )
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -37,6 +37,7 @@
     ObjectNotInActiveTierError,
     NoSystemTags,
     PreconditionFailed,
+    InvalidRange,
 )
 from .models import (
     s3_backend,
@@ -936,11 +937,15 @@ def toint(i):
         else:
             return 400, response_headers, ""
         if begin < 0 or end > last or begin > min(end, last):
-            return 416, response_headers, ""
+            raise InvalidRange(
+                actual_size=str(length), range_requested=request.headers.get("range")
+            )
         response_headers["content-range"] = "bytes {0}-{1}/{2}".format(
             begin, end, length
         )
-        return 206, response_headers, response_content[begin : end + 1]
+        content = response_content[begin : end + 1]
+        response_headers["content-length"] = len(content)
+        return 206, response_headers, content
 
     def key_or_control_response(self, request, full_url, headers):
         # Key and Control are lumped in because splitting out the regex is too much of a pain :/
@@ -967,9 +972,12 @@ def key_or_control_response(self, request, full_url, headers):
             status_code, response_headers, response_content = response
 
         if status_code == 200 and "range" in request.headers:
-            return self._handle_range_header(
-                request, response_headers, response_content
-            )
+            try:
+                return self._handle_range_header(
+                    request, response_headers, response_content
+                )
+            except S3ClientError as s3error:
+                return s3error.code, {}, s3error.description
         return status_code, response_headers, response_content
 
     def _control_response(self, request, full_url, headers):


Test patch:
diff --git a/tests/test_s3/red.jpg b/tests/test_s3/red.jpg
new file mode 100644
Binary files /dev/null and b/tests/test_s3/red.jpg differ
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -4889,3 +4889,37 @@ def test_presigned_put_url_with_custom_headers():
 
     s3.delete_object(Bucket=bucket, Key=key)
     s3.delete_bucket(Bucket=bucket)
+
+
+@mock_s3
+def test_request_partial_content_should_contain_content_length():
+    bucket = "bucket"
+    object_key = "key"
+    s3 = boto3.resource("s3")
+    s3.create_bucket(Bucket=bucket)
+    s3.Object(bucket, object_key).put(Body="some text")
+
+    file = s3.Object(bucket, object_key)
+    response = file.get(Range="bytes=0-1024")
+    response["ContentLength"].should.equal(9)
+
+
+@mock_s3
+def test_request_partial_content_should_contain_actual_content_length():
+    bucket = "bucket"
+    object_key = "key"
+    s3 = boto3.resource("s3")
+    s3.create_bucket(Bucket=bucket)
+    s3.Object(bucket, object_key).put(Body="some text")
+
+    file = s3.Object(bucket, object_key)
+    requested_range = "bytes=1024-"
+    try:
+        file.get(Range=requested_range)
+    except botocore.client.ClientError as e:
+        e.response["Error"]["Code"].should.equal("InvalidRange")
+        e.response["Error"]["Message"].should.equal(
+            "The requested range is not satisfiable"
+        )
+        e.response["Error"]["ActualObjectSize"].should.equal("9")
+        e.response["Error"]["RangeRequested"].should.equal(requested_range)