Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

S3 200 errors implementation #3276

Merged
merged 20 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-s3-47846.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "``s3``",
"description": "Handle HTTP 200 responses with error information for all supported s3 operations."
}
45 changes: 35 additions & 10 deletions botocore/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def escape_xml_payload(params, **kwargs):


def check_for_200_error(response, **kwargs):
"""This function has been deprecated, but is kept for backwards compatibility."""
# From: http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectCOPY.html
# There are two opportunities for a copy request to return an error. One
# can occur when Amazon S3 receives the copy request and the other can
Expand Down Expand Up @@ -162,13 +163,13 @@ def check_for_200_error(response, **kwargs):
http_response.status_code = 500


def _looks_like_special_case_error(http_response):
if http_response.status_code == 200:
def _looks_like_special_case_error(status_code, body):
if status_code == 200 and body:
try:
parser = ETree.XMLParser(
target=ETree.TreeBuilder(), encoding='utf-8'
)
parser.feed(http_response.content)
parser.feed(body)
root = parser.close()
except XMLParseError:
# In cases of network disruptions, we may end up with a partial
Expand Down Expand Up @@ -1239,6 +1240,35 @@ def document_expires_shape(section, event_name, **kwargs):
)


def _handle_200_error(operation_model, response_dict, **kwargs):
# S3 can return a 200 response with an error embedded in the body.
# Convert the 200 to a 500 for retry resolution in ``_retry_200_error``.
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
if not response_dict or operation_model.has_streaming_output:
# Operations with streaming response blobs are excluded as they
# can't be reliably distinguished from an S3 error.
return
if _looks_like_special_case_error(
response_dict['status_code'], response_dict['body']
):
response_dict['status_code'] = 500
logger.debug(
f"Error found for response with 200 status code: {response_dict['body']}."
)


def _update_status_code(response, **kwargs):
# Update the http_response status code when the parsed response has been
# modified in a handler. This enables retries for cases like ``_handle_200_error``.
if response is None:
return
http_response, parsed = response
parsed_status_code = parsed.get('ResponseMetadata', {}).get(
'HTTPStatusCode'
)
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
if http_response.status_code != parsed_status_code:
http_response.status_code = parsed_status_code


# This is a list of (event_name, handler).
# When a Session is created, everything in this list will be
# automatically registered with that Session.
Expand Down Expand Up @@ -1269,6 +1299,7 @@ def document_expires_shape(section, event_name, **kwargs):
('after-call.cloudformation.GetTemplate', json_decode_template_body),
('after-call.s3.GetBucketLocation', parse_get_bucket_location),
('before-parse.s3.*', handle_expires_header),
('before-parse.s3.*', _handle_200_error, REGISTER_FIRST),
('before-parameter-build', generate_idempotent_uuid),
('before-parameter-build.s3', validate_bucket_name),
('before-parameter-build.s3', remove_bucket_from_url_paths_from_model),
Expand Down Expand Up @@ -1312,13 +1343,7 @@ def document_expires_shape(section, event_name, **kwargs):
('before-call.ec2.CopySnapshot', inject_presigned_url_ec2),
('request-created', add_retry_headers),
('request-created.machinelearning.Predict', switch_host_machinelearning),
('needs-retry.s3.UploadPartCopy', check_for_200_error, REGISTER_FIRST),
('needs-retry.s3.CopyObject', check_for_200_error, REGISTER_FIRST),
(
'needs-retry.s3.CompleteMultipartUpload',
check_for_200_error,
REGISTER_FIRST,
),
('needs-retry.s3.*', _retry_200_error, REGISTER_FIRST),
('choose-signer.cognito-identity.GetId', disable_signing),
('choose-signer.cognito-identity.GetOpenIdToken', disable_signing),
('choose-signer.cognito-identity.UnlinkIdentity', disable_signing),
Expand Down
32 changes: 22 additions & 10 deletions tests/functional/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
UnsupportedS3AccesspointConfigurationError,
UnsupportedS3ConfigurationError,
)
from botocore.parsers import ResponseParserError
from tests import (
BaseSessionTest,
ClientHTTPStubber,
Expand Down Expand Up @@ -435,21 +434,20 @@ def create_stubbed_s3_client(self, **kwargs):
http_stubber.start()
return client, http_stubber

def test_s3_copy_object_with_empty_response(self):
def test_s3_copy_object_with_incomplete_response(self):
self.client, self.http_stubber = self.create_stubbed_s3_client(
region_name="us-east-1"
)

empty_body = b""
incomplete_body = b'<?xml version="1.0" encoding="UTF-8"?>\n\n\n'
complete_body = (
b'<?xml version="1.0" encoding="UTF-8"?>\n\n'
b"<CopyObjectResult "
b'xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
b"<LastModified>2020-04-21T21:03:31.000Z</LastModified>"
b"<ETag>&quot;s0mEcH3cK5uM&quot;</ETag></CopyObjectResult>"
)

self.http_stubber.add_response(status=200, body=empty_body)
self.http_stubber.add_response(status=200, body=incomplete_body)
self.http_stubber.add_response(status=200, body=complete_body)
response = self.client.copy_object(
Bucket="bucket",
Expand All @@ -462,19 +460,33 @@ def test_s3_copy_object_with_empty_response(self):
self.assertEqual(response["ResponseMetadata"]["HTTPStatusCode"], 200)
self.assertTrue("CopyObjectResult" in response)

def test_s3_copy_object_with_incomplete_response(self):
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
def test_s3_copy_object_with_200_error_response(self):
self.client, self.http_stubber = self.create_stubbed_s3_client(
region_name="us-east-1"
)

incomplete_body = b'<?xml version="1.0" encoding="UTF-8"?>\n\n\n'
self.http_stubber.add_response(status=200, body=incomplete_body)
with self.assertRaises(ResponseParserError):
error_body = (
b"<Error>"
b"<Code>SlowDown</Code>"
b"<Message>Please reduce your request rate.</Message>"
b"</Error>"
)
# Populate 5 retries for SlowDown
for i in range(5):
self.http_stubber.add_response(status=200, body=error_body)
with self.assertRaises(botocore.exceptions.ClientError) as context:
self.client.copy_object(
Bucket="bucket",
CopySource="other-bucket/test.txt",
Key="test.txt",
)
self.assertEqual(len(self.http_stubber.requests), 5)
self.assertEqual(
context.exception.response["ResponseMetadata"]["HTTPStatusCode"],
500,
)
self.assertEqual(
context.exception.response["Error"]["Code"], "SlowDown"
)


class TestAccesspointArn(BaseS3ClientConfigurationTest):
Expand Down
110 changes: 80 additions & 30 deletions tests/unit/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,33 +501,6 @@ def test_presigned_url_casing_changed_for_rds(self):
)
self.assertIn('X-Amz-Signature', params['PreSignedUrl'])

def test_500_status_code_set_for_200_response(self):
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
http_response = mock.Mock()
http_response.status_code = 200
http_response.content = """
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
<RequestId>id</RequestId>
<HostId>hostid</HostId>
</Error>
"""
handlers.check_for_200_error((http_response, {}))
self.assertEqual(http_response.status_code, 500)

def test_200_response_with_no_error_left_untouched(self):
http_response = mock.Mock()
http_response.status_code = 200
http_response.content = "<NotAnError></NotAnError>"
handlers.check_for_200_error((http_response, {}))
# We don't touch the status code since there are no errors present.
self.assertEqual(http_response.status_code, 200)

def test_500_response_can_be_none(self):
# A 500 response can raise an exception, which means the response
# object is None. We need to handle this case.
handlers.check_for_200_error(None)

def test_route53_resource_id(self):
event = 'before-parameter-build.route53.GetHostedZone'
params = {
Expand Down Expand Up @@ -1202,14 +1175,14 @@ def test_s3_special_case_is_before_other_retry(self):
caught_exception=None,
)
# This is implementation specific, but we're trying to verify that
# the check_for_200_error is before any of the retry logic in
# the _retry_200_error is before any of the retry logic in
# botocore.retryhandlers.
# Technically, as long as the relative order is preserved, we don't
# care about the absolute order.
names = self.get_handler_names(responses)
self.assertIn('check_for_200_error', names)
self.assertIn('_retry_200_error', names)
self.assertIn('RetryHandler', names)
s3_200_handler = names.index('check_for_200_error')
s3_200_handler = names.index('_retry_200_error')
general_retry_handler = names.index('RetryHandler')
self.assertTrue(
s3_200_handler < general_retry_handler,
Expand Down Expand Up @@ -1944,3 +1917,80 @@ def test_document_response_params_without_expires(document_expires_mocks):
mocks['section'].get_section.assert_not_called()
mocks['param_section'].add_new_section.assert_not_called()
mocks['doc_section'].write.assert_not_called()


@pytest.fixture()
def operation_model_for_200_error():
operation_model = mock.Mock()
operation_model.has_streaming_output = False
return operation_model


@pytest.fixture()
def response_dict_for_200_error():
return {
'status_code': 200,
'body': (
b"<Error>"
b"<Code>SlowDown</Code>"
b"<Message>Please reduce your request rate.</Message>"
b"</Error>"
),
}


def test_500_status_code_set_for_200_response(
operation_model_for_200_error, response_dict_for_200_error
):
handlers._handle_200_error(
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
operation_model_for_200_error, response_dict_for_200_error
)
assert response_dict_for_200_error['status_code'] == 500


def test_200_response_with_no_error_left_untouched(
operation_model_for_200_error, response_dict_for_200_error
):
response_dict_for_200_error['body'] = b"<NotAnError/>"
handlers._handle_200_error(
operation_model_for_200_error, response_dict_for_200_error
)
# We don't touch the status code since there are no errors present.
assert response_dict_for_200_error['status_code'] == 200


def test_200_response_with_streaming_output_left_untouched(
operation_model_for_200_error,
response_dict_for_200_error,
):
operation_model_for_200_error.has_streaming_output = True
handlers._handle_200_error(
operation_model_for_200_error, response_dict_for_200_error
)
# We don't touch the status code on streaming operations.
assert response_dict_for_200_error['status_code'] == 200


def test_200_response_with_no_body_left_untouched(
operation_model_for_200_error, response_dict_for_200_error
):
response_dict_for_200_error['body'] = b""
handlers._handle_200_error(
operation_model_for_200_error, response_dict_for_200_error
)
assert response_dict_for_200_error['status_code'] == 200


def test_http_status_code_updated_to_retry_200_response():
http_response = mock.Mock()
http_response.status_code = 200
parsed = {}
parsed.setdefault('ResponseMetadata', {})['HTTPStatusCode'] = 500
handlers._retry_200_error((http_response, parsed))
assert http_response.status_code == 500


def test_500_response_can_be_none():
# A 500 response can raise an exception, which means the response
# object is None. We need to handle this case.
handlers._retry_200_error(None)
alexgromero marked this conversation as resolved.
Show resolved Hide resolved
Loading