Skip to content

Fix Content-Length and retries for seekable streaming payloads - #853

Merged
jonathan343 merged 5 commits into
developfrom
fix-content-length
Oct 19, 2026
Merged

jonathan343 merged 5 commits into
developfrom
fix-content-length

Conversation

@jonathan343

Copy link
Copy Markdown
Contributor

Summary

Streaming payloads given as a file or BytesIO (for example the body of S3 PutObject) were sent without a Content-Length. aiohttp then fell back to Transfer-Encoding: chunked, which S3 rejects. When the caller also set the modeled ContentLength, the header was sent and signed twice. @requiresLength payloads always failed to serialize, and operations with a streaming input were never retried.

This PR measures seekable streams from their current position when the request is serialized, lets a modeled Content-Length take precedence, and retries seekable streaming uploads by rewinding them to where they started. Streams whose length can't be determined are still sent without a Content-Length, and event streams are still never retried.

Before / After

Before

file-like body:           None
modeled Content-Length:   ['5', '5']
@requiresLength stream:   SerializationError: This operation requires the content length of the input stream, but it was not provided and was unable to be computed.

After

file-like body:           ['11']
modeled Content-Length:   ['5']
@requiresLength stream:   ['11']
Repro
from io import BytesIO

from smithy_core.prelude import BLOB, LONG
from smithy_core.schemas import Schema
from smithy_core.shapes import ShapeID
from smithy_core.traits import (
    HTTPHeaderTrait,
    HTTPPayloadTrait,
    HTTPTrait,
    RequiresLengthTrait,
    StreamingTrait,
)
from smithy_http.serializers import HTTPBindingSerializer
from smithy_json import JSONCodec


def put(body, content_length=None, requires_length=False):
    traits = [HTTPPayloadTrait(), StreamingTrait()]
    if requires_length:
        traits.append(RequiresLengthTrait())
    schema = Schema.collection(
        id=ShapeID("com.example#PutInput"),
        members={
            "body": {"target": BLOB, "traits": traits},
            "contentLength": {
                "target": LONG,
                "traits": [HTTPHeaderTrait("Content-Length")],
            },
        },
    )
    serializer = HTTPBindingSerializer(
        payload_codec=JSONCodec(),
        schema=schema,
        http_trait=HTTPTrait({"method": "PUT", "code": 200, "uri": "/"}),
    )
    serializer.write_data_stream(schema.members["body"], body)
    if content_length is not None:
        serializer.write_long(schema.members["contentLength"], content_length)
    try:
        fields = serializer.build_request().fields
    except Exception as e:
        return f"{type(e).__name__}: {e}"
    return fields["content-length"].values if "content-length" in fields else None


print("file-like body:          ", put(BytesIO(b"hello world")))
print("modeled Content-Length:  ", put(b"hello", content_length=5))
print("@requiresLength stream:  ", put(BytesIO(b"hello world"), requires_length=True))

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

The HTTP serializers only set Content-Length for sized payloads, so files
and BytesIO bodies were sent without one and aiohttp fell back to
Transfer-Encoding: chunked, which S3 rejects. Synchronously seekable
streams are now measured from their current position to the end without
consuming them. Streams whose length can't be determined, including
async streams, still get no Content-Length.

A Content-Length the caller already set, such as S3's modeled
ContentLength member, now takes precedence. The serializers used to
append their own length too, sending and signing "3, 3".

This also fixes @requiresLength request payloads. Their length could
never be computed, because the request serializer measured the async
wrapper rather than the caller's stream. An unset payload of such an
operation is sent with a length of 0.

The length is computed at serialization, before signing, as the Go v2,
Java v2 and Rust SDKs do, so steps that need it before signing, such as
aws-chunked encoding, have it.
Operations with a streaming input were never retried, so a throttled
or failed upload from a file raised instead of being resent. A data
stream that can be rewound is now retried, and each retry rewinds it to
the position it had before the first attempt rather than to 0, so a
caller that skipped the first bytes of a file doesn't have them sent.
Event streams, and streams that can't be rewound or fail to seek, are
still sent once.
Whether a request could be retried was split between retryable(),
which reported every streaming operation as not retryable, and a check
of the request future in the retry loop that told event streams apart.
retryable() now returns False only for operations with an input event
stream, and the retry loop checks only whether the body can be rewound.
Event streams no longer depend on how the pipeline was invoked, and the
rate-limiting tests no longer need a body that can't be rewound.

If seeking a body back fails before a retry, the call now returns the
attempt's error with a note, as when a send token times out, instead of
failing with the seek error.
The file payload test went through the same code as the BytesIO one,
and the @requiresLength modeled-length test repeated the
duplicate-header test, so it's now a parameter of it.
The coroutine-returning tell and seek fixtures and the stream whose
tell fails only at its end don't match any stream callers pass, and the
async seek case already covers what _is_async is for. The two tests for
data streams that can't be rewound are now one parametrized test.
@jonathan343
jonathan343 requested a review from a team as a code owner October 18, 2026 23:38
@jonathan343 jonathan343 changed the title Fix content length Fix Content-Length and retries for seekable streaming payloads Oct 18, 2026
@jonathan343

Copy link
Copy Markdown
Contributor Author

The Build smithy-python / aws-sdk-pr-build (pull_request) failure is related to and will be addressed by: aws/aws-sdk-python#112. This failure is independent of this PR.

@jonathan343
jonathan343 merged commit 534c650 into develop Oct 19, 2026
10 of 12 checks passed
@jonathan343
jonathan343 deleted the fix-content-length branch October 19, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants