#!/usr/bin/env python3
"""Repro: botocore's Expect: 100-continue handling permanently poisons a pooled
keep-alive connection against RFC 9110-compliant servers (Go net/http: SeaweedFS,
MinIO, ...) that answer a zero-byte PUT with the final response and no interim
"100 Continue".
Mechanism (botocore 1.42.70, awsrequest.py):
1. add_expect_header() puts "Expect: 100-continue" on every S3 PUT/POST with a
file-like body, including zero-byte bodies (handlers.py:376).
2. Go net/http never sends an interim 100 when Content-Length == 0 -- the final
200 arrives in the interim slot. AWSConnection._handle_expect_response()
reads that early final status line and swaps self.response_class to
functools.partial(AWSHTTPResponse, status_tuple=(..., 200, ...)) on the
pooled connection.
3. AWSConnection.request() resets response_class only for requests WITHOUT an
Expect header, so in a burst of PUTs the poison persists.
4. The next non-empty PUT on that connection replays the stale status tuple
without reading the socket: the real status line is fed to the header
parser -> urllib3 "Failed to parse headers" (MissingHeaderBodySeparatorDefect,
full real response visible in `unparsed data`) -> header-less 200 with no
Content-Length -> read-until-EOF -> read timeout stall -> silent retry on a
fresh connection -> success. The object is written twice server-side.
Server (terminal 1) -- SeaweedFS 4.18 requires an identity config, injected inline:
docker run --rm -p 18333:8333 --entrypoint sh chrislusf/seaweedfs:4.18 -c \
'printf "{\\"identities\\":[{\\"name\\":\\"repro\\",\\"credentials\\":[{\\"accessKey\\":\\"repro\\",\\"secretKey\\":\\"repro\\"}],\\"actions\\":[\\"Admin\\",\\"Read\\",\\"Write\\",\\"List\\",\\"Tagging\\"]}]}" > /tmp/s3.json \
&& exec weed server -s3 -s3.config=/tmp/s3.json -volume.max=100 -master.volumeSizeLimitMB=100'
Client (terminal 2):
python repro.py # bug reproduced
BOTO_EXPERIMENTAL__NO_EMPTY_CONTINUE=true python repro.py # mitigation 1: clean
REPRO_STRIP_EXPECT=1 python repro.py # mitigation 2: clean
python repro.py --fake-server # no docker needed; in-process python server
# that mimics Go's RFC-compliant behavior
Tested with Python 3.13.12, boto3 1.42.70, botocore 1.42.70, urllib3 2.6.3.
"""
import collections
import io
import logging
import os
import socket
import sys
import threading
import time
import uuid
import boto3
import botocore
import urllib3
from botocore.config import Config
from botocore.exceptions import ClientError
ENDPOINT = os.environ.get("REPRO_ENDPOINT", "http://127.0.0.1:18333")
READ_TIMEOUT = 5
FAKE = "--fake-server" in sys.argv
STRIP_EXPECT = os.environ.get("REPRO_STRIP_EXPECT", "") == "1"
NO_EMPTY_CONTINUE = os.environ.get("BOTO_EXPERIMENTAL__NO_EMPTY_CONTINUE", "")
def start_fake_go_server():
"""Mimic Go net/http's RFC 9110 Expect handling: interim 100 only when the
request has a non-empty body; for Content-Length: 0 the final response is
sent immediately (no 100). Counts server-side PUTs per path."""
put_counts = collections.Counter()
srv = socket.create_server(("127.0.0.1", 0))
def handle(conn):
f = conn.makefile("rb")
try:
while True:
request_line = f.readline()
if not request_line:
return
method, path, _ = request_line.decode().split(" ", 2)
headers = {}
while (line := f.readline()) not in (b"\r\n", b"\n", b""):
name, _, value = line.decode().partition(":")
headers[name.strip().lower()] = value.strip()
clen = int(headers.get("content-length", 0))
if headers.get("expect", "").lower() == "100-continue" and clen > 0:
conn.sendall(b"HTTP/1.1 100 Continue\r\n\r\n")
if clen:
f.read(clen)
if method == "PUT":
put_counts[path] += 1
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b'Etag: "d41d8cd98f00b204e9800998ecf8427e"\r\n'
b"Content-Length: 0\r\n\r\n"
)
except (ConnectionError, ValueError, OSError):
pass
finally:
conn.close()
def accept_loop():
while True:
try:
c, _ = srv.accept()
except OSError:
return
threading.Thread(target=handle, args=(c,), daemon=True).start()
threading.Thread(target=accept_loop, daemon=True).start()
return f"http://127.0.0.1:{srv.getsockname()[1]}", put_counts
def make_client(endpoint):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id="repro",
aws_secret_access_key="repro",
region_name="us-east-1",
config=Config(
s3={"addressing_style": "path"},
max_pool_connections=1, # everything over ONE keep-alive connection
connect_timeout=5,
read_timeout=READ_TIMEOUT,
retries={"mode": "standard", "max_attempts": 3},
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
),
)
def main():
mode = "fake-go-server" if FAKE else "seaweedfs"
mitigation = (
"BOTO_EXPERIMENTAL__NO_EMPTY_CONTINUE"
if NO_EMPTY_CONTINUE
else ("strip-Expect-hook" if STRIP_EXPECT else "none")
)
print(f"python {sys.version.split()[0]}")
print(f"boto3 {boto3.__version__}")
print(f"botocore {botocore.__version__}")
print(f"urllib3 {urllib3.__version__}")
print(f"server {mode} mitigation: {mitigation}")
# Capture urllib3's "Failed to parse headers" warning.
records = []
class Capture(logging.Handler):
def emit(self, record):
records.append(record)
# urllib3 2.x emits it from urllib3.connection; 1.x from urllib3.connectionpool.
urllib3_logger = logging.getLogger("urllib3")
urllib3_logger.addHandler(Capture())
urllib3_logger.setLevel(logging.WARNING)
run = uuid.uuid4().hex[:8] # unique key prefix so reruns don't collide
put_counts = None
if FAKE:
endpoint, put_counts = start_fake_go_server()
bucket, versioning = "any-bucket", False
else:
endpoint, bucket = ENDPOINT, "expect-repro"
# Separate client for setup: bucket creation/versioning use non-file-like
# bodies, get no Expect header, and stay off the demo connection.
setup = make_client(endpoint)
try:
setup.create_bucket(Bucket=bucket)
except ClientError:
pass # BucketAlreadyExists / BucketAlreadyOwnedByYou on rerun
try:
setup.put_bucket_versioning(
Bucket=bucket, VersioningConfiguration={"Status": "Enabled"}
)
versioning = True
except ClientError:
versioning = False # server build without versioning support
# Warm up volume assignment so demo PUT timings are clean.
setup.put_object(Bucket=bucket, Key=f"{run}/warmup", Body=b"w" * 16)
setup.close()
s3 = make_client(endpoint)
if STRIP_EXPECT:
def strip_expect(request, **kwargs):
request.headers.pop("Expect", None)
s3.meta.events.register("before-send.s3", strip_expect)
results = []
def put(name, size):
# io.BytesIO: add_expect_header only fires for file-like bodies
# (hasattr(body, "read")) -- exactly what upload streams look like.
t0 = time.monotonic()
r = s3.put_object(Bucket=bucket, Key=f"{run}/{name}", Body=io.BytesIO(b"x" * size))
dt = time.monotonic() - t0
md = r["ResponseMetadata"]
print(
f"PUT {name:<8} {size:>5}B -> HTTP {md['HTTPStatusCode']} "
f"RetryAttempts={md['RetryAttempts']} wall={dt:6.2f}s"
)
results.append((name, md["RetryAttempts"], dt))
return r
print(f"\n--- 3 PUTs over one keep-alive connection (read_timeout={READ_TIMEOUT}s) ---")
put("a-4kb", 4096) # healthy connection baseline
put("b-empty", 0) # zero-byte PUT: no interim 100 -> connection poisoned
put("c-4kb", 4096) # replays stale status tuple -> warning + stall + retry
print("\n--- urllib3 warnings captured ---")
parse_warnings = [r for r in records if "Failed to parse headers" in r.getMessage()]
if parse_warnings:
for r in parse_warnings:
print(r.getMessage())
else:
print("(none)")
print("\n--- server-side write count for the final key ---")
if FAKE:
n = put_counts[f"/{bucket}/{run}/c-4kb"]
print(f"fake server observed {n} PUT(s) for /{bucket}/{run}/c-4kb")
double_write = n
elif versioning:
v = s3.list_object_versions(Bucket=bucket, Prefix=f"{run}/c-4kb").get(
"Versions", []
)
print(f"bucket versioning shows {len(v)} version(s) of c-4kb")
double_write = len(v)
else:
print("(versioning unavailable; retry count above implies duplicate PUT)")
double_write = None
stalled = results[2][2] >= READ_TIMEOUT
retried = results[2][1] > 0
print("\n--- verdict ---")
if parse_warnings and retried and stalled:
extra = f", object written {double_write}x" if double_write else ""
print(
f"BUG REPRODUCED: header-parse failure + {READ_TIMEOUT}s stall + "
f"silent retry on final PUT{extra}"
)
sys.exit(0)
if not parse_warnings and not any(retry for _, retry, _ in results):
extra = f", object written {double_write}x" if double_write else ""
print(f"CLEAN: no warnings, no stalls, no retries{extra}")
sys.exit(0)
print("UNEXPECTED: partial reproduction, see output above")
sys.exit(1)
if __name__ == "__main__":
main()
Describe the bug
When a
PutObjectwith an empty body is sent over a pooled keep-alive connection to an S3-compatible server built on Go'snet/http(SeaweedFS, MinIO, …), botocore'sExpect: 100-continuehandling permanently corrupts that connection object, and the next request on the connection has its response parsed against a stale, stashed status tuple instead of the socket.Root cause walk-through (botocore 1.42.70):
handlers.add_expect_headeraddsExpect: 100-continueto every S3 PUT (registered onbefore-call.s3) — including zero-length bodies.net/httpis RFC 9110-compliant: for a request withContent-Length: 0it never emits an interim100 Continue(server.go:if req.ProtoAtLeast(1, 1) && req.ContentLength != 0). RFC 9110 §10.1.1 explicitly permits omitting 100 "if the framing indicates that there is no content". So the final 200 arrives in the interim-response slot, within botocore's 1-secondwait_for_readwindow (awsrequest.py,AWSConnection._send_output).AWSConnection._handle_expect_responsereads that early status line and — in its non-100 branch — permanently swapsself.response_classtofunctools.partial(AWSHTTPResponse, status_tuple=(...))so the already-consumed status line can be replayed for this response.AWSConnection.request()resetsresponse_classback to the default only in the code path taken by requests that do not carryExpect. In a burst of PUTs, every request carriesExpect, so the poisonedresponse_classpersists on the pooled connection.100 Continue+ a final response — butAWSHTTPResponse._read_statusreplays the stale stashed tuple without reading the socket. Header parsing then starts at the real (unread) status line and the client is left holding a status-only response with no headers.The wire is clean throughout — we verified with a byte-logging TCP tap that the server sends exactly one well-formed response per request. The "corrupt" bytes exist only in the client's parse.
Regression Issue
Expected Behavior
Expect: 100-continueagainst a server that (per RFC 9110 §10.1.1) skips the interim100 Continueshould complete cleanly and leave the pooled connection in a clean state.read_timeoutstall followed by an invisible retry.Current Behavior
On the first non-empty PUT after a zero-length PUT on the same keep-alive connection:
http.clientrecordsMissingHeaderBodySeparatorDefect; urllib3 logsFailed to parse headerswith the entire real, well-formed response captured asunparsed_data.Content-Length→ framing falls back to read-until-EOF on a kept-alive socket → the call blocks for the fullread_timeout(60s with defaults).ReadTimeoutErrorinternally and silently retries, re-executing the request on a fresh connection (close()restoresresponse_class, which is why retries always succeed). The operation reports success withRetryAttempts=1; the object is written twice server-side.Observed output from the reproduction below (SeaweedFS 4.18,
read_timeout=5to keep the stall short):Reproduction Steps
Deterministic (3/3 consecutive runs, byte-identical artifact shape). The script performs three PUTs over one keep-alive connection:
PUT 4KB → PUT 0B → PUT 4KB.Option A — real S3-compatible Go server (SeaweedFS 4.18):
Option B — no docker needed:
runs a ~45-line in-process Python socket server that mimics exactly Go net/http's RFC-compliant Expect handling (interim 100 only when the body is non-empty). Same artifact.
Sanity checks that isolate the trigger — both of these make the same run fully clean (no warning,
RetryAttempts=0, single version written):repro.py (self-contained)
Possible Solution
Reset
self.response_classunconditionally at the start ofAWSConnection.request()(or clear the stashed tuple once it has been consumed by the response of the request that stashed it). The stashed-tuple mechanism is only valid for the response of the one request that stashed it; it must never survive into the next request on the pooled connection.Workarounds we validated in the meantime:
BOTO_EXPERIMENTAL__NO_EMPTY_CONTINUE=true(skipsExpectfor zero-length bodies) eliminates the empty-body trigger.Expectentirely via abefore-sendhandler (request.headers.pop("Expect", None)) eliminates the whole class; the header is not SigV4-signed (SignedHeaders=host;x-amz-content-sha256;x-amz-date), so this is signature-safe.Additional Information/Context
__init__.pyfiles).wall=1.01son the first non-empty PUT is botocore'swait_for_readinterim wait burning a full second per PUT against servers that send100 Continuelazily — a separate cost of the same code path.net/httpserver; closed there as client-side).SDK version used
1.42.70
Environment details (OS name and version, etc.)
weed version "30GB 4.18 24805ff47 linux amd64") — but reproducible against any Gonet/httpserver, and against the included pure-Python mock (--fake-server)