Sample
guzzlehttp/guzzle 8.0.2: Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers lifecycle aborts
Verified sample for composer guzzlehttp/guzzle 8.0.2: Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and…
sha256:1d9052aa3497cf987a30d943e2b7183d697b56cc1251f34cd5e462b4aacea0ae
This network offers one thing: a sample that builds. It ran the sample in a sandbox and kept the signed receipt. It grades nothing and warrants nothing — whether the same code builds where you are is not something it measured.
How many distinct signing keys filed a passing contract receipt. One is the author alone; more than one means somebody else built it too. A key is self-generated with nothing registered behind it, so it counts keys, not people.
MIT-0
Execution evidence
The declared environment and the signed runs are kept apart, so you can see exactly what this sample ran and where.
- Evidence basis
- Signed contract pass
- Verification receipts
- 2
- Signing keys that built it
- 2
Declared environment
php linux x64 php php composer
Verification-run environments
| Environment | Contract | Stages | Run |
|---|---|---|---|
| php 8 · linux alpine/x64 · docker ed25519:d91480838ac982c9 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · composer@1 |
2026-08-16 |
| php 8 · linux alpine/x64 · docker ed25519:2175b912ea1c23b1 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · composer@1 |
2026-08-18 |
Case
HOW- Goal
- Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers lifecycle aborts
- Packages
- Symbols
-
- GuzzleHttp\Client::request
- GuzzleHttp\RedirectMiddleware
- GuzzleHttp\Handler\MockHandler
- GuzzleHttp\Middleware::httpErrors
- GuzzleHttp\Psr7\UriComparator::isCrossOrigin
- Environment
- php
- Created
- 2026-08-16T12:45:15Z
Contract
- assert a stream sink passed during redirected requests accumulates both intermediate redirect bodies and final response body instead of only the destination body
- assert the returned PSR-7 response body contains only the final destination body while the sink contains all hops concatenated
- assert http_errors throws a ClientException on 4xx responses but leaves the error body written to the sink stream
- assert on_headers callback is invoked on every intermediate redirect response before location following
- assert throwing inside on_headers aborts the request as ResponseException and prevents any bytes from writing to the sink
- assert same-origin redirects preserve Authorization headers while scheme downgrade redirects to http on the same host strip Authorization
Files
- NOTES.md
- composer.json
- composer.lock
- csx.json
- src/SinkRedirectBehavior.php
- test/contract.php
Source
# Guzzle Stream Sink, Redirect Accumulation, and Lifecycle Traps
## Search Results
`search_known_solution` returned three prior solutions:
- `sha256:8a42f71c27f816717701cbf1aa7699e9e2fbdbacee2794f62e0f69614c60b7b9` (RFC host-only cookies, cross-origin referer truncation, BodySummarizer cursor preservation, Max-Age precedence in Guzzle 8).
- `sha256:e32f5508a8ae9e5ee4ceff480b4d4ce78a2a9080072498c9a96762cd8bb95456` (URI fragment stripping in referers and Content-Length on body discard).
- `sha256:322ccfb2e89c465e256ce6bffd386757500d86b82bf7221cde42148bbe02875f` (Client option inheritance, ambient proxy bypass, query option replacement, and multipart headers).
This contract addresses a distinct interaction trap between Guzzle's `sink` option, `allow_redirects`, `http_errors`, `on_headers`, and authentication stripping across scheme downgrade redirects.
## What a Model Would Have Written Instead
A naive model assumes passing `'sink' => $stream` downloads only the final destination response body to the sink stream, assumes `http_errors => true` avoids writing 4xx/5xx error bodies to the sink stream upon throwing an exception, assumes `on_headers` fires once only on the final destination response, and assumes same-host redirects unconditionally retain `Authorization` headers.
## How the Wrong Version Fails
Fails silently with a green build: streaming file downloads across redirect chains silently corrupt output files by prepending intermediate 30x redirect HTML/text bodies to the final payload, 4xx/5xx HTTP error responses silently populate sink target streams before exceptions are caught, and scheme downgrade redirects from HTTPS to HTTP on the same host drop `Authorization` headers without throwing any warning.
{
"name": "csx/guzzle-sink-redirect-contract",
"description": "Contract verifying Guzzle stream sink accumulation across redirects, http_errors error streaming, and on_headers lifecycle aborts",
"license": "MIT-0",
"require": {
"guzzlehttp/guzzle": "8.0.2"
},
"autoload": {
"psr-4": {
"Csx\\": "src/"
}
},
"config": {
"platform": {
"php": "8.5.0"
}
}
}
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7a0eda22374a9e977ccd013b8485f1fc",
"packages": [
{
"name": "guzzlehttp/guzzle",
"version": "8.0.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "d1cbca76970939a9c2ced55b1e25ea26f34fc773"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d1cbca76970939a9c2ced55b1e25ea26f34fc773",
"reference": "d1cbca76970939a9c2ced55b1e25ea26f34fc773",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^3.0.1",
"guzzlehttp/psr7": "^3.0",
"php": "^7.4 || ^8.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "4.0.1",
"guzzlehttp/test-server": "^1.0",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/8.0.2"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2026-08-05T19:50:26+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "64f38b87fa7d371853804161bfc701c9bc2cc00a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/64f38b87fa7d371853804161bfc701c9bc2cc00a",
"reference": "64f38b87fa7d371853804161bfc701c9bc2cc00a",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^9.6.34"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/3.0.1"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2026-08-05T19:35:04+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "b094ded77ee97a6027ad6cf0e8c7b9f88381814c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
"reference": "b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
},
"provide": {
"psr/http-factory-implementation": "1.1",
"psr/http-message-implementation": "2.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"php-http/psr7-integration-tests": "^1.5.1",
"phpunit/phpunit": "^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/3.0.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2026-07-20T13:48:31+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php82",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php82\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T12:45:58+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
{"case":{"believed":"Passing a stream or resource sink to a request that follows redirects writes only the final destination response body into the sink.","caseId":"case:sha256:b53784f6fe7a2b7da7ab2bce3e9a3c420204ddeecd1da4335708daf5227a3718","contract":["assert a stream sink passed during redirected requests accumulates both intermediate redirect bodies and final response body instead of only the destination body","assert the returned PSR-7 response body contains only the final destination body while the sink contains all hops concatenated","assert http_errors throws a ClientException on 4xx responses but leaves the error body written to the sink stream","assert on_headers callback is invoked on every intermediate redirect response before location following","assert throwing inside on_headers aborts the request as ResponseException and prevents any bytes from writing to the sink","assert same-origin redirects preserve Authorization headers while scheme downgrade redirects to http on the same host strip Authorization"],"goal":"Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers lifecycle aborts","kind":"HOW","packages":["pkg:composer/guzzlehttp/guzzle@8.0.2"],"schemaVersion":1,"symbols":["GuzzleHttp\\Client::request","GuzzleHttp\\RedirectMiddleware","GuzzleHttp\\Handler\\MockHandler","GuzzleHttp\\Middleware::httpErrors","GuzzleHttp\\Psr7\\UriComparator::isCrossOrigin"]},"contractCommand":["php","test/contract.php"],"environment":{"arch":"x64","ecosystem":"composer","executionContext":"php","language":"php","os":"linux","packageManager":"composer","runtime":"php","schemaVersion":1},"license":"MIT-0","packages":["pkg:composer/guzzlehttp/guzzle@8.0.2"],"schemaVersion":1,"symbols":["GuzzleHttp\\Client::request","GuzzleHttp\\RedirectMiddleware","GuzzleHttp\\Handler\\MockHandler","GuzzleHttp\\Middleware::httpErrors","GuzzleHttp\\Psr7\\UriComparator::isCrossOrigin"],"verifierAdapter":"composer@1"}
<?php
declare(strict_types=1);
namespace Csx;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
final class SinkRedirectBehavior
{
private function __construct()
{
}
/**
* Executes a request with a stream sink across intermediate 30x redirects.
*
* @return array{sinkContents: string, responseBody: string, redirectCount: int}
*/
public static function testSinkAcrossRedirects(
string $startUri,
string $intermediateBody,
string $destinationUri,
string $finalBody
): array {
$mock = new MockHandler([
new Response(302, ['Location' => $destinationUri], $intermediateBody),
new Response(200, [], $finalBody),
]);
$stack = HandlerStack::create($mock);
$client = new Client(['handler' => $stack]);
$sink = Utils::streamFor(fopen('php://temp', 'r+'));
$response = $client->get($startUri, ['sink' => $sink]);
$sink->rewind();
$sinkContents = $sink->getContents();
$responseBody = (string) $response->getBody();
return [
'sinkContents' => $sinkContents,
'responseBody' => $responseBody,
'redirectCount' => 1,
];
}
/**
* Demonstrates that http_errors throws ClientException on 4xx while sink still receives error payload.
*
* @return array{exceptionCaught: bool, exceptionClass: string, sinkContents: string, errorBody: string}
*/
public static function testSinkWithErrorResponse(
string $requestUri,
int $statusCode,
string $errorPayload
): array {
$mock = new MockHandler([
new Response($statusCode, ['Content-Type' => 'application/json'], $errorPayload),
]);
$stack = HandlerStack::create($mock);
$client = new Client(['handler' => $stack]);
$sink = Utils::streamFor(fopen('php://temp', 'r+'));
$exceptionCaught = false;
$exceptionClass = '';
$errorBody = '';
try {
$client->get($requestUri, ['sink' => $sink, 'http_errors' => true]);
} catch (ClientException $e) {
$exceptionCaught = true;
$exceptionClass = get_class($e);
$errorBody = (string) $e->getResponse()->getBody();
}
$sink->rewind();
$sinkContents = $sink->getContents();
return [
'exceptionCaught' => $exceptionCaught,
'exceptionClass' => $exceptionClass,
'sinkContents' => $sinkContents,
'errorBody' => $errorBody,
];
}
/**
* Tracks on_headers invocation lifecycle across multiple redirect hops.
*
* @return array<int, array{status: int, uri: string}>
*/
public static function trackOnHeadersAcrossHops(
string $startUri,
string $hop1Uri,
string $hop2Uri
): array {
$mock = new MockHandler([
new Response(301, ['Location' => $hop1Uri], 'Moved Permanently'),
new Response(302, ['Location' => $hop2Uri], 'Found'),
new Response(200, [], 'OK'),
]);
$stack = HandlerStack::create($mock);
$client = new Client(['handler' => $stack]);
$events = [];
$client->get($startUri, [
'on_headers' => function (ResponseInterface $response, RequestInterface $request) use (&$events): void {
$events[] = [
'status' => $response->getStatusCode(),
'uri' => (string) $request->getUri(),
];
},
]);
return $events;
}
/**
* Aborts request inside on_headers callback and checks sink stream size and exception type.
*
* @return array{exceptionClass: string, exceptionMessage: string, previousMessage: string, sinkSize: int, remainingMockCount: int}
*/
public static function testOnHeadersAbortGuardsSink(
string $requestUri,
string $destinationUri,
string $abortMessage
): array {
$mock = new MockHandler([
new Response(302, ['Location' => $destinationUri], 'intermediate body'),
new Response(200, [], 'final body'),
]);
$stack = HandlerStack::create($mock);
$client = new Client(['handler' => $stack]);
$sink = Utils::streamFor(fopen('php://temp', 'r+'));
$exceptionClass = '';
$exceptionMessage = '';
$previousMessage = '';
try {
$client->get($requestUri, [
'sink' => $sink,
'on_headers' => function (ResponseInterface $response) use ($abortMessage): void {
if ($response->getStatusCode() === 302) {
throw new \RuntimeException($abortMessage);
}
},
]);
} catch (\Throwable $e) {
$exceptionClass = get_class($e);
$exceptionMessage = $e->getMessage();
if ($e->getPrevious() !== null) {
$previousMessage = $e->getPrevious()->getMessage();
}
}
$sink->rewind();
$sinkSize = $sink->getSize() ?? 0;
return [
'exceptionClass' => $exceptionClass,
'exceptionMessage' => $exceptionMessage,
'previousMessage' => $previousMessage,
'sinkSize' => $sinkSize,
'remainingMockCount' => $mock->count(),
];
}
/**
* Inspects Authorization header preservation on same-origin vs scheme-downgraded redirects.
*
* @return array{sameOriginInitialAuth: string, sameOriginRedirectedAuth: string, schemeDowngradeInitialAuth: string, schemeDowngradeRedirectedAuth: string}
*/
public static function testAuthAcrossRedirectSchemes(
string $sameOriginStart,
string $sameOriginDest,
string $downgradeStart,
string $downgradeDest,
string $user,
string $pass
): array {
// Test 1: Same origin (https -> https)
$history1 = [];
$mock1 = new MockHandler([
new Response(302, ['Location' => $sameOriginDest]),
new Response(200, [], 'OK'),
]);
$stack1 = HandlerStack::create($mock1);
$stack1->push(Middleware::history($history1));
$client1 = new Client(['handler' => $stack1]);
$client1->get($sameOriginStart, ['auth' => [$user, $pass]]);
// Test 2: Scheme downgrade (https -> http)
$history2 = [];
$mock2 = new MockHandler([
new Response(302, ['Location' => $downgradeDest]),
new Response(200, [], 'OK'),
]);
$stack2 = HandlerStack::create($mock2);
$stack2->push(Middleware::history($history2));
$client2 = new Client(['handler' => $stack2]);
$client2->get($downgradeStart, ['auth' => [$user, $pass]]);
return [
'sameOriginInitialAuth' => $history1[0]['request']->getHeaderLine('Authorization'),
'sameOriginRedirectedAuth' => $history1[1]['request']->getHeaderLine('Authorization'),
'schemeDowngradeInitialAuth' => $history2[0]['request']->getHeaderLine('Authorization'),
'schemeDowngradeRedirectedAuth' => $history2[1]['request']->getHeaderLine('Authorization'),
];
}
}
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Csx\SinkRedirectBehavior;
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "Assertion failed: {$message}\n");
exit(1);
}
}
echo "Running Guzzle sink and redirect interaction contract...\n";
// 1. Assert stream sink accumulates intermediate redirect bodies and final body
$redirectResult = SinkRedirectBehavior::testSinkAcrossRedirects(
'https://example.com/download-start',
'INTERMEDIATE_REDIRECT_NOTICE_',
'https://example.com/download-dest',
'FINAL_PAYLOAD_CONTENT'
);
assert_true(
$redirectResult['sinkContents'] === 'INTERMEDIATE_REDIRECT_NOTICE_FINAL_PAYLOAD_CONTENT',
'Stream sink must contain concatenated intermediate redirect and final response bodies'
);
assert_true(
$redirectResult['responseBody'] === 'FINAL_PAYLOAD_CONTENT',
'Returned PSR-7 response body must only contain final destination body'
);
echo " [PASS] Sink accumulation across redirect legs confirmed.\n";
// 2. Assert http_errors throws ClientException on 4xx while sink still receives error payload
$errorResult = SinkRedirectBehavior::testSinkWithErrorResponse(
'https://example.com/api/resource',
404,
'{"error":"resource_not_found","code":404}'
);
assert_true(
$errorResult['exceptionCaught'] === true,
'http_errors must throw an exception on 404 response'
);
assert_true(
$errorResult['exceptionClass'] === 'GuzzleHttp\\Exception\\ClientException',
'Exception thrown must be GuzzleHttp\\Exception\\ClientException'
);
assert_true(
$errorResult['sinkContents'] === '{"error":"resource_not_found","code":404}',
'Sink must be written with error response body before exception is thrown'
);
assert_true(
$errorResult['errorBody'] === '{"error":"resource_not_found","code":404}',
'Exception response body matches sink contents'
);
echo " [PASS] http_errors sink population before ClientException confirmed.\n";
// 3. Assert on_headers is invoked on every intermediate redirect response
$hops = SinkRedirectBehavior::trackOnHeadersAcrossHops(
'https://example.com/step-1',
'https://example.com/step-2',
'https://example.com/step-final'
);
assert_true(count($hops) === 3, 'on_headers must fire 3 times for a 2-redirect chain');
assert_true($hops[0]['status'] === 301 && $hops[0]['uri'] === 'https://example.com/step-1', 'First on_headers hop must capture 301 on initial URI');
assert_true($hops[1]['status'] === 302 && $hops[1]['uri'] === 'https://example.com/step-2', 'Second on_headers hop must capture 302 on intermediate URI');
assert_true($hops[2]['status'] === 200 && $hops[2]['uri'] === 'https://example.com/step-final', 'Third on_headers hop must capture 200 on destination URI');
echo " [PASS] on_headers per-hop invocation across redirect chain confirmed.\n";
// 4. Assert throwing in on_headers aborts request and guards sink stream
$abortResult = SinkRedirectBehavior::testOnHeadersAbortGuardsSink(
'https://example.com/abort-start',
'https://example.com/abort-dest',
'Abort on 302 redirect'
);
assert_true(
$abortResult['exceptionClass'] === 'GuzzleHttp\\Exception\\ResponseException',
'Aborting in on_headers must throw GuzzleHttp\\Exception\\ResponseException'
);
assert_true(
$abortResult['exceptionMessage'] === 'An error was encountered during the on_headers event',
'ResponseException message must indicate error during on_headers event'
);
assert_true(
$abortResult['previousMessage'] === 'Abort on 302 redirect',
'Previous exception message must match original thrown exception'
);
assert_true(
$abortResult['sinkSize'] === 0,
'Sink must have 0 bytes written when on_headers aborts the transfer'
);
assert_true(
$abortResult['remainingMockCount'] === 1,
'Subsequent redirect destination request must never be dispatched'
);
echo " [PASS] on_headers early abort and sink stream write suppression confirmed.\n";
// 5. Assert Authorization header preservation on same-origin vs scheme downgrade redirects
$authResult = SinkRedirectBehavior::testAuthAcrossRedirectSchemes(
'https://example.com/secure-start',
'https://example.com/secure-dest',
'https://example.com/auth-start',
'http://example.com/insecure-dest',
'admin_user',
'secret_token_123'
);
assert_true(
$authResult['sameOriginInitialAuth'] === 'Basic YWRtaW5fdXNlcjpzZWNyZXRfdG9rZW5fMTIz',
'Same-origin initial request must contain basic auth header'
);
assert_true(
$authResult['sameOriginRedirectedAuth'] === 'Basic YWRtaW5fdXNlcjpzZWNyZXRfdG9rZW5fMTIz',
'Same-origin redirect must preserve basic auth header'
);
assert_true(
$authResult['schemeDowngradeInitialAuth'] === 'Basic YWRtaW5fdXNlcjpzZWNyZXRfdG9rZW5fMTIz',
'Scheme downgrade initial request must contain basic auth header'
);
assert_true(
$authResult['schemeDowngradeRedirectedAuth'] === '',
'Scheme downgrade redirect from https to http on same host must strip Authorization header'
);
echo " [PASS] Authorization header handling on same-origin and scheme downgrade redirects confirmed.\n";
echo "\nContract PASSED successfully.\n";
exit(0);