CodeSampleX

샘플

guzzlehttp/guzzle 8.0.2: Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers lifecycle aborts

검증된 샘플 — composer guzzlehttp/guzzle 8.0.2: Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers…

sha256:1d9052aa3497cf987a30d943e2b7183d697b56cc1251f34cd5e462b4aacea0ae

이 네트워크가 제공하는 것은 하나입니다. 빌드되는 샘플. 샌드박스에서 돌리고 서명된 영수증을 보관합니다. 등급을 매기지 않고 무엇도 보증하지 않습니다 — 같은 코드가 당신 환경에서 빌드되는지는 측정한 적이 없습니다. 통과한 계약 영수증을 낸 서로 다른 서명 키의 수입니다. 하나면 작성자 혼자이고, 둘 이상이면 다른 사람도 빌드했다는 뜻입니다. 키는 스스로 만드는 것이고 뒤에 등록된 신원이 없으므로, 세는 것은 사람이 아니라 키입니다. MIT-0

실행 증거

선언된 환경과 서명된 실행을 분리해 두었습니다. 이 샘플이 무엇을 어디서 실행했는지 그대로 볼 수 있습니다.

증거 기준
서명된 컨트랙트 통과
검증 영수증
2
빌드한 서명 키
2
선언된 환경 php linux x64 php php composer

검증 실행 환경

환경 컨트랙트 단계 실행일
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

케이스

HOW
목표
Manage Guzzle sink stream persistence across intermediate redirects, error response streaming, and on_headers lifecycle aborts
패키지
심벌
  • GuzzleHttp\Client::request
  • GuzzleHttp\RedirectMiddleware
  • GuzzleHttp\Handler\MockHandler
  • GuzzleHttp\Middleware::httpErrors
  • GuzzleHttp\Psr7\UriComparator::isCrossOrigin
환경
php
생성일
2026-08-16T12:45:15Z

컨트랙트

  1. assert a stream sink passed during redirected requests accumulates both intermediate redirect bodies and final response body instead of only the destination body
  2. assert the returned PSR-7 response body contains only the final destination body while the sink contains all hops concatenated
  3. assert http_errors throws a ClientException on 4xx responses but leaves the error body written to the sink stream
  4. assert on_headers callback is invoked on every intermediate redirect response before location following
  5. assert throwing inside on_headers aborts the request as ResponseException and prevents any bytes from writing to the sink
  6. assert same-origin redirects preserve Authorization headers while scheme downgrade redirects to http on the same host strip Authorization

파일

  • NOTES.md
  • composer.json
  • composer.lock
  • csx.json
  • src/SinkRedirectBehavior.php
  • test/contract.php

소스 아티팩트 내려받기 (tar.gz)

소스

NOTES.md
# 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.
composer.json
{
    "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"
        }
    }
}
composer.lock
{
    "_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"
}
csx.json
{"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"}
src/SinkRedirectBehavior.php
<?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'),
        ];
    }
}
test/contract.php
<?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);

오리진 시더

csx-seed