Repository navigation
Translate delete-marker versions to FileNotFoundError and fix two S3Core edge cases (GH-1338) #1345
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import functools | ||
| import logging | ||
| import math | ||
| import traceback | ||
|
|
@@ -620,7 +621,11 @@ def call(self, method: str | Callable[..., Any], **request) -> dict[str, Any]: | |
| """Send a request with the retry policy and translate its errors. | ||
|
|
||
| ``request_kwargs`` that the operation accepts are added; a parameter | ||
| of the request takes precedence. | ||
| of the request takes precedence. The operation is identified by the | ||
| name of the method; a ``functools.partial`` is identified by the | ||
| method it wraps, and its bound keywords are parameters of the request. | ||
| A callable whose ``__name__`` is not that of a client method is sent | ||
| without them. | ||
|
|
||
| Args: | ||
| method: The name of the client method, or the method itself. | ||
|
|
@@ -641,7 +646,12 @@ def call(self, method: str | Callable[..., Any], **request) -> dict[str, Any]: | |
| """ | ||
| func = getattr(self._client, method) if isinstance(method, str) else method | ||
| if self._request_kwargs: | ||
| request = {**self.operation_params(func.__name__, self._request_kwargs), **request} | ||
| target, bound = func, set(request) | ||
| while isinstance(target, functools.partial): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (behavior, failure paths, data boundaries, simplicity, test quality), base Finding 1 (s3_core.py:646 at 1de9476). Repair (decided with the maintainer: unwrap partials). Nested partials are unwrapped to the client method they wrap, and that operation's |
||
| bound.update(target.keywords) | ||
| target = target.func | ||
| params = self.operation_params(getattr(target, "__name__", ""), self._request_kwargs) | ||
| request.update({k: v for k, v in params.items() if k not in bound}) | ||
| try: | ||
| response = retry_api_call(func, config=self._retry_config, logger=_logger, **request) | ||
| except BaseException as e: | ||
|
|
@@ -1115,8 +1125,10 @@ def part_ranges(self, size: int, block_size: int) -> list[tuple[int, int]]: | |
|
|
||
| Returns: | ||
| The ``(start, end)`` byte ranges, with an exclusive end, that | ||
| cover the whole object in order. | ||
| cover the whole object in order; empty for an empty object. | ||
| """ | ||
| if size == 0: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Round one, finding 2 (s3_core.py:1125 at 1de9476).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correction from round two: the writer method is |
||
| return [] | ||
| block_size = max(block_size, math.ceil(size / self.MULTIPART_UPLOAD_MAX_PARTS)) | ||
| starts = list(range(0, size, block_size)) | ||
| if len(starts) > 1 and size - starts[-1] < self.MULTIPART_UPLOAD_MIN_PART_SIZE: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,7 +35,9 @@ class S3ClientError: | |
| by its HTTP status code; if neither is recognized, a generic ``OSError`` | ||
| with the original error message is used. A ``PreconditionFailed`` error | ||
| of an ``If-None-Match`` condition, from a write that must not replace an | ||
| existing object, is mapped to ``FileExistsError``. | ||
| existing object, is mapped to ``FileExistsError``. A ``405`` error with | ||
| the ``x-amz-delete-marker: true`` header, from a request for a version | ||
| that is a delete marker, is mapped to ``FileNotFoundError``. | ||
|
|
||
| Example: | ||
| >>> try: | ||
|
|
@@ -108,14 +110,21 @@ def __init__(self, error: botocore.exceptions.ClientError) -> None: | |
| self._code: str = str(error_info.get("Code", "")) | ||
| self._message: str = str(error_info.get("Message", error)) | ||
| self._condition: str = str(error_info.get("Condition", "")) | ||
| status_code = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode") | ||
| response_metadata = error.response.get("ResponseMetadata", {}) | ||
| status_code = response_metadata.get("HTTPStatusCode") | ||
| self._http_status_code: int | None = int(status_code) if status_code is not None else None | ||
| headers = response_metadata.get("HTTPHeaders", {}) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent review (relayed result), static only.
Result: CLEAN. The reviewer reports that botocore supplies lowercased headers under The snapshot and the PR worktree were unchanged after the review (clean status, same head). |
||
| self._delete_marker: bool = headers.get("x-amz-delete-marker") == "true" | ||
| self._os_error: OSError = self._translate() | ||
|
|
||
| def _translate(self) -> OSError: | ||
| if self._code == "PreconditionFailed" and self._condition == "If-None-Match": | ||
| # A conditional write (IfNoneMatch="*") found an existing object. | ||
| return FileExistsError(self._message) | ||
| if self._http_status_code == 405 and self._delete_marker: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Round one, deferred and rejected:
|
||
| # HeadObject/GetObject of a version that is a delete marker. | ||
| # https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html | ||
| return FileNotFoundError(self._message) | ||
| exception = self._ERROR_CODE_TO_EXCEPTION.get(self._code) | ||
| if exception: | ||
| return exception(self._message) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,15 @@ | |
| from pyathena.filesystem.s3_errors import S3ClientError | ||
|
|
||
|
|
||
| def _client_error(code, message="error message", status_code=None, condition=None): | ||
| def _client_error(code, message="error message", status_code=None, condition=None, headers=None): | ||
| error_response = {"Error": {"Code": code, "Message": message}} | ||
| if condition is not None: | ||
| error_response["Error"]["Condition"] = condition | ||
| if status_code is not None: | ||
| error_response["ResponseMetadata"] = {"HTTPStatusCode": status_code} | ||
| if headers is not None: | ||
| # botocore lowercases the names of the response headers. | ||
| error_response.setdefault("ResponseMetadata", {})["HTTPHeaders"] = headers | ||
| return botocore.exceptions.ClientError(error_response, "TestOperation") | ||
|
|
||
|
|
||
|
|
@@ -109,3 +112,21 @@ def test_os_error_precondition_failed(self, condition, expected): | |
| if expected is OSError: | ||
| assert actual.errno == errno.EINVAL | ||
| assert "error message" in str(actual) | ||
|
|
||
| @pytest.mark.parametrize( | ||
| "code", | ||
| [ | ||
| # HEAD responses have no body, so the code is the status code. | ||
| "405", | ||
| "MethodNotAllowed", | ||
| ], | ||
| ) | ||
| def test_os_error_delete_marker(self, code): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Round one, finding 3 (test placement). The delete-marker rule lives in |
||
| # GH-1338: HeadObject/GetObject of a version that is a delete marker | ||
| # is answered with 405 and the x-amz-delete-marker header, and the | ||
| # version does not exist as an object. Other 405 errors map to EPERM. | ||
| actual = S3ClientError( | ||
| _client_error(code, status_code=405, headers={"x-amz-delete-marker": "true"}) | ||
| ).os_error | ||
| assert type(actual) is FileNotFoundError | ||
| assert "error message" in str(actual) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Self-review round two (claims, existing callers, AWS operator, documentation reader, evidence), base
766d79d0af49f9bcd14ccf4f08d9751c976e523e, head0b9aa8f6fe41f7b1f523e0f4953bbc88a8c848fb. Result: FINDINGS (one wording error), corrected.Claims checked against evidence:
x-amz-delete-marker: true. AWS "Working with delete markers" (userguide/DeleteMarker.html) states both. The HeadObject and GetObject API pages state the 405.exists()returns False, andinfo()/cat_file()/open()/S3Core.head_object()/get_object()raiseFileNotFoundError. I ran each offline against this branch with a stubbed client:exists -> False, the others raisedFileNotFoundError.info()re-raises for a versioned path without a listing fallback (s3.py:428-436).AioS3FileSystem._existsdelegates to the sync method.test_os_error_by_http_status_code[405]is unchanged and passes.S3Coredocstring anddocs/filesystem.mdalready promisedFileNotFoundErrorfor a missing version. Yes:s3_core.py:497-503anddocs/filesystem.md:400-402. No other doc mentions 405 or a delete-marker error, and therequest_kwargsprose (docs/filesystem.md:482-486, presigned URLs) is unaffected.part_ranges().part_ranges(-1, 5 MiB)raisesValueError(verified).plan_multipart_copyreturns beforepart_rangesfor sizes that fit one CopyObject, so size 0 never reaches it there.partial(partial(c.head_object, Bucket=...))was sent withRequestPayer(Stubberexpected_params, verified).ResponseMetadata.HTTPHeaders.botocore/parsers.py:282(lowercase_dict(headers)) at the resolved botocore.part_rangesgives 1.AWS operator: 405 is not in the retry policy's exceptions, and translation happens after
retry_api_call, so attempts and elapsed time are unchanged. No request is added.Existing callers: the only exception change is
PermissionError→FileNotFoundErrorfor delete-marker versions, recorded under Compatibility. A partial withrequest_kwargsused to raiseAttributeError, so no working caller changes.Correction: the PR body and the round-one record named
S3MultipartWriter.copy_ranges(). The method isiter_copy_parts(); both are fixed.Deferred: the first commit's message describes the superseded drop-silently behavior for partials. The second commit's message records the replacement, so the history is not rewritten.
Limits: no live S3 run has created a real delete marker. The versioning fixtures are opt-in with a 15-minute wait. CI runs the S3 integration suites once the PR is Ready.