Describe the bug
When a GetObject download with SaveAs (or any streamed sink) fails mid-body with a transport error — e.g. the connection drops after the server has sent a 200 and part of the payload — the SDK's error-parsing path reads the entire partial body back into a single PHP string, exhausting memory_limit in proportion to how many bytes were downloaded before the failure. A long-running worker process downloading multi-gigabyte objects can be killed by a transient network failure. Because the process dies with a fatal error inside the rejection handler, the OOM also preempts the SDK's retry logic, which would otherwise have classified the connection error as retryable and recovered.
Mechanism chain (all refs against master):
- Guzzle attaches the partial
200 response to the rejected promise, so WrappedHttpHandler::parseError receives a response object whose body is the sink stream containing everything downloaded so far.
parseError invokes the error parser unconditionally whenever a response is present (src/WrappedHttpHandler.php:175-180), without checking the status code — even though a 2xx attached to a transport rejection is a partial success payload, not an error document.
- For S3 that's
XmlErrorParser::__invoke, which calls AbstractParser::getBodyContents($response) (src/Api/ErrorParser/XmlErrorParser.php:42).
getBodyContents (src/Api/Parser/AbstractParser.php:48-56) does $body->rewind(); ... return $body->getContents(); — an unbounded read of the whole sink.
- The sink's
LazyOpenStream::getContents() lands in GuzzleHttp\Psr7\Utils::copyToString with $maxLen === -1, whose loop concatenates the entire file into one string (guzzlehttp/psr7 src/Utils.php, the $buffer .= $buf; at line 124 is where the fatal fires).
The JSON protocols are equally affected via JsonParserTrait::genericHandler (src/Api/ErrorParser/JsonParserTrait.php:43), and RpcV2CborErrorParser via (string) $stream in src/Api/Parser/RpcV2ParserTrait.php:92.
Regression Issue
Not a regression. getBodyContents was introduced in #3214 (first shipped in 3.371.5), but older releases had the same unbounded read in a different shape: in 3.300.0, XmlErrorParser passed the body to parseXml, whose new \SimpleXMLElement($xml) string-casts the PSR-7 stream (rewind + full read), and JsonParserTrait::genericHandler did the same via json_decode. A file-backed sink reports a real getSize() > 0, so the old size guard did not help either.
Expected Behavior
The mid-body transport failure surfaces as a retryable S3Exception/connection error, and memory stays bounded regardless of how much of the object was downloaded before the failure.
Current Behavior
Fatal OOM in the rejection path, sized to the partial download (verbatim from the reproduction below, memory_limit=128M, ~300 MiB downloaded before the drop):
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 98566176 bytes) in /app/vendor/guzzlehttp/psr7/src/Utils.php on line 124
The 98566176-byte allocation is the doubling string concat inside copyToString's unbounded loop.
Reproduction Steps
Self-contained; no AWS account needed. A one-shot mock server sends a 200 with a large Content-Length, streams ~300 MiB, then closes the connection mid-body.
docker run -it --name aws-sdk-php-oom-repro php:8.4-cli bash
# ---- everything below runs inside the container ----
apt-get update && apt-get install -y --no-install-recommends unzip git
mkdir -p /app && cd /app
curl -sS https://getcomposer.org/installer | php
php composer.phar require aws/aws-sdk-php
php -v
php composer.phar show | grep -E 'aws/aws-sdk-php|guzzlehttp/guzzle |guzzlehttp/psr7'
/tmp/mock-server.php:
<?php
// Mock HTTP server: accept connections, serve exactly ONE GET request with a
// 200 response promising a large Content-Length, stream ~300 MiB, then close
// the connection mid-body (simulates a network failure during a download).
$server = stream_socket_server('tcp://127.0.0.1:8098', $errno, $errstr);
if ($server === false) {
fwrite(STDERR, "listen failed: $errstr\n");
exit(1);
}
fwrite(STDERR, "listening\n");
do {
$conn = stream_socket_accept($server, 300);
if ($conn === false) {
fwrite(STDERR, "accept timed out\n");
exit(1);
}
stream_set_timeout($conn, 5);
$firstLine = fgets($conn);
if ($firstLine !== false && str_starts_with($firstLine, 'GET ')) {
break;
}
fclose($conn); // ignore port probes
} while (true);
while (($line = fgets($conn)) !== false && trim($line) !== '') {
}
fwrite($conn, "HTTP/1.1 200 OK\r\n"
. "Content-Type: binary/octet-stream\r\n"
. "Content-Length: 700000000\r\n"
. "Connection: close\r\n\r\n");
$chunk = str_repeat('A', 1048576);
for ($i = 0; $i < 300; $i++) {
if (@fwrite($conn, $chunk) === false) {
break;
}
}
fclose($conn); // ~400 MB short of the promised Content-Length
fwrite(STDERR, "closed mid-body after ~{$i} MiB\n");
/tmp/repro.php:
<?php
require '/app/vendor/autoload.php';
use Aws\S3\S3Client;
$client = new S3Client([
'region' => 'us-east-1',
'version' => '2006-03-01',
'endpoint' => 'http://127.0.0.1:8098',
'use_path_style_endpoint' => true,
'credentials' => ['key' => 'test', 'secret' => 'test'],
]);
echo 'memory_limit=' . ini_get('memory_limit') . PHP_EOL;
try {
$client->getObject([
'Bucket' => 'test-bucket',
'Key' => 'large-object.bin',
'SaveAs' => '/tmp/large-object.bin',
]);
echo 'UNEXPECTED SUCCESS' . PHP_EOL;
} catch (\Throwable $e) {
echo 'Caught ' . get_class($e) . ': ' . substr($e->getMessage(), 0, 250) . PHP_EOL;
}
echo 'peak_memory_bytes=' . memory_get_peak_usage(true) . PHP_EOL;
echo 'partial_file_bytes=' . (file_exists('/tmp/large-object.bin') ? filesize('/tmp/large-object.bin') : 0) . PHP_EOL;
Run:
rm -f /tmp/large-object.bin
php /tmp/mock-server.php & sleep 1
php -d memory_limit=128M /tmp/repro.php
Result: the fatal quoted under Current Behavior. /tmp/large-object.bin contains the 300 MiB partial body, confirming the sink streamed to disk fine — only the error-parse read blows up.
Possible Solution
Primary: status-gate the error parser in WrappedHttpHandler::parseError. When $err['response']->getStatusCode() < 300, skip the error-parser call (lines 175-180) and fall back to the same handling as the no-response case, still setting $parts['response'] = $err['response'] (line 190). A 2xx attached to a transport rejection has no error document to extract — today it either OOMs or throws ParserException anyway. This mirrors the existing gate on the success side: parseResponse line 125-127, $result = $status < 300 ? $parser($command, $response) : new Result();.
Two interactions checked:
- S3 200-with-error-XML is unaffected.
S3Parser::parse200Error runs in the promise fulfillment path (S3Parser::__invoke → called only from parseResponse), while parseError runs only in the rejection path — disjoint branches, so a CopyObject/CompleteMultipartUpload 200-with-<Error> never touches parseError. parse200Error is also already bounded: it sniffs only 64 bytes (src/S3/Parser/S3Parser.php:177).
- Retry classification is unchanged.
RetryMiddlewareV2::isRetryable uses isConnectionError(), curl errno, and status code alongside the parsed error code/shape — and for a partial 2xx payload the parse already fails today (parseError's catch (ParserException) at lines 184-185 yields empty $parts), so skipping the parse produces the same classification inputs.
Alternative: bounded read at the error-parser call sites. Add a $maxLen parameter to AbstractParser::getBodyContents (defaulting to -1), delegating to GuzzleHttp\Psr7\Utils::copyToString($body, $maxLen), and have the four error-path callers (XmlErrorParser.php:42/:105, JsonParserTrait.php:43/:137) pass a cap. Real error documents are a few KB; 64 KiB is generous. Caveats: the cap must not be unconditional inside getBodyContents — success-path callers (QueryParser.php:44, RestJsonParser.php:31, AbstractRestParser.php:46/:100) need full bodies, so a global cap would silently truncate large legitimate responses. RpcV2CborErrorParser bypasses getBodyContents ((string) $stream in RpcV2ParserTrait.php:92) and would need the same bounding. The primary fix covers all parsers and reads nothing on transport errors, so it seems preferable.
Additional Information/Context
Fix verified in the same container: capping the error-parser read at 1 MiB (Utils::copyToString($body, 1048576) in getBodyContents, as a proof of concept) turns the same scenario into a normally-caught exception with bounded memory:
memory_limit=128M
Caught Aws\S3\Exception\S3Exception: Error executing "GetObject" on "http://127.0.0.1:8098/test-bucket/large-object.bin"; AWS HTTP error:
cURL error 7: Failed to connect to 127.0.0.1 port 8098 after 0 ms: Could not connect to server (see https://curl.se/libcurl/c/libcurl-errors.html) fo
peak_memory_bytes=23068672
partial_file_bytes=314572800
Peak memory ~22 MiB vs. the 128M fatal, while the full 300 MiB partial body still streamed to disk. (The "cURL error 7" is the SDK's retry reaching the one-shot mock server after it exited — i.e., the retry now actually runs; the message is truncated at 250 chars by the repro script.)
SDK version used
3.387.2
Environment details (Version of PHP (php -v)? OS name and version, etc.)
PHP 8.4.23 (cli) (built: Jul 2 2026 20:39:24) (NTS); docker image php:8.4-cli; guzzlehttp/guzzle 7.13.2; guzzlehttp/psr7 2.12.3
Per CONTRIBUTING.md's Automated Tools policy: this issue was generated by AI tools, and reviewed by the author before submission.
Describe the bug
When a
GetObjectdownload withSaveAs(or any streamed sink) fails mid-body with a transport error — e.g. the connection drops after the server has sent a200and part of the payload — the SDK's error-parsing path reads the entire partial body back into a single PHP string, exhaustingmemory_limitin proportion to how many bytes were downloaded before the failure. A long-running worker process downloading multi-gigabyte objects can be killed by a transient network failure. Because the process dies with a fatal error inside the rejection handler, the OOM also preempts the SDK's retry logic, which would otherwise have classified the connection error as retryable and recovered.Mechanism chain (all refs against
master):200response to the rejected promise, soWrappedHttpHandler::parseErrorreceives a response object whose body is the sink stream containing everything downloaded so far.parseErrorinvokes the error parser unconditionally whenever a response is present (src/WrappedHttpHandler.php:175-180), without checking the status code — even though a2xxattached to a transport rejection is a partial success payload, not an error document.XmlErrorParser::__invoke, which callsAbstractParser::getBodyContents($response)(src/Api/ErrorParser/XmlErrorParser.php:42).getBodyContents(src/Api/Parser/AbstractParser.php:48-56) does$body->rewind(); ... return $body->getContents();— an unbounded read of the whole sink.LazyOpenStream::getContents()lands inGuzzleHttp\Psr7\Utils::copyToStringwith$maxLen === -1, whose loop concatenates the entire file into one string (guzzlehttp/psr7 src/Utils.php, the$buffer .= $buf;at line 124 is where the fatal fires).The JSON protocols are equally affected via
JsonParserTrait::genericHandler(src/Api/ErrorParser/JsonParserTrait.php:43), andRpcV2CborErrorParservia(string) $streaminsrc/Api/Parser/RpcV2ParserTrait.php:92.Regression Issue
Not a regression.
getBodyContentswas introduced in #3214 (first shipped in 3.371.5), but older releases had the same unbounded read in a different shape: in 3.300.0,XmlErrorParserpassed the body toparseXml, whosenew \SimpleXMLElement($xml)string-casts the PSR-7 stream (rewind + full read), andJsonParserTrait::genericHandlerdid the same viajson_decode. A file-backed sink reports a realgetSize() > 0, so the old size guard did not help either.Expected Behavior
The mid-body transport failure surfaces as a retryable
S3Exception/connection error, and memory stays bounded regardless of how much of the object was downloaded before the failure.Current Behavior
Fatal OOM in the rejection path, sized to the partial download (verbatim from the reproduction below,
memory_limit=128M, ~300 MiB downloaded before the drop):The
98566176-byte allocation is the doubling string concat insidecopyToString's unbounded loop.Reproduction Steps
Self-contained; no AWS account needed. A one-shot mock server sends a
200with a largeContent-Length, streams ~300 MiB, then closes the connection mid-body./tmp/mock-server.php:/tmp/repro.php:Run:
Result: the fatal quoted under Current Behavior.
/tmp/large-object.bincontains the 300 MiB partial body, confirming the sink streamed to disk fine — only the error-parse read blows up.Possible Solution
Primary: status-gate the error parser in
WrappedHttpHandler::parseError. When$err['response']->getStatusCode() < 300, skip the error-parser call (lines 175-180) and fall back to the same handling as the no-response case, still setting$parts['response'] = $err['response'](line 190). A2xxattached to a transport rejection has no error document to extract — today it either OOMs or throwsParserExceptionanyway. This mirrors the existing gate on the success side:parseResponseline 125-127,$result = $status < 300 ? $parser($command, $response) : new Result();.Two interactions checked:
S3Parser::parse200Errorruns in the promise fulfillment path (S3Parser::__invoke→ called only fromparseResponse), whileparseErrorruns only in the rejection path — disjoint branches, so aCopyObject/CompleteMultipartUpload200-with-<Error>never touchesparseError.parse200Erroris also already bounded: it sniffs only 64 bytes (src/S3/Parser/S3Parser.php:177).RetryMiddlewareV2::isRetryableusesisConnectionError(), curl errno, and status code alongside the parsed error code/shape — and for a partial2xxpayload the parse already fails today (parseError'scatch (ParserException)at lines 184-185 yields empty$parts), so skipping the parse produces the same classification inputs.Alternative: bounded read at the error-parser call sites. Add a
$maxLenparameter toAbstractParser::getBodyContents(defaulting to-1), delegating toGuzzleHttp\Psr7\Utils::copyToString($body, $maxLen), and have the four error-path callers (XmlErrorParser.php:42/:105,JsonParserTrait.php:43/:137) pass a cap. Real error documents are a few KB; 64 KiB is generous. Caveats: the cap must not be unconditional insidegetBodyContents— success-path callers (QueryParser.php:44,RestJsonParser.php:31,AbstractRestParser.php:46/:100) need full bodies, so a global cap would silently truncate large legitimate responses.RpcV2CborErrorParserbypassesgetBodyContents((string) $streaminRpcV2ParserTrait.php:92) and would need the same bounding. The primary fix covers all parsers and reads nothing on transport errors, so it seems preferable.Additional Information/Context
Fix verified in the same container: capping the error-parser read at 1 MiB (
Utils::copyToString($body, 1048576)ingetBodyContents, as a proof of concept) turns the same scenario into a normally-caught exception with bounded memory:Peak memory ~22 MiB vs. the 128M fatal, while the full 300 MiB partial body still streamed to disk. (The "cURL error 7" is the SDK's retry reaching the one-shot mock server after it exited — i.e., the retry now actually runs; the message is truncated at 250 chars by the repro script.)
SDK version used
3.387.2
Environment details (Version of PHP (
php -v)? OS name and version, etc.)PHP 8.4.23 (cli) (built: Jul 2 2026 20:39:24) (NTS); docker image
php:8.4-cli; guzzlehttp/guzzle 7.13.2; guzzlehttp/psr7 2.12.3Per CONTRIBUTING.md's Automated Tools policy: this issue was generated by AI tools, and reviewed by the author before submission.