diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f078744..7e369ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,14 @@ jobs: extensions: curl, swoole coverage: none + # utopia-php/pools -> utopia-php/telemetry declares ext-opentelemetry and + # ext-protobuf, but only the no-op telemetry path is used at runtime, so the + # packages are installed without loading the extensions (loading them + # alongside swoole segfaults the test process on PHP 8.4). - name: Install dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + composer-options: "--ignore-platform-req=ext-opentelemetry --ignore-platform-req=ext-protobuf" - name: Audit dependencies run: composer audit diff --git a/CHANGELOG.md b/CHANGELOG.md index e358086..aad9133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,15 @@ This project follows semantic versioning. - PSR-7 message implementations and PSR-17 factories under `Utopia\Psr7`. - Request factories for JSON, XML, plain-text, form-encoded, query-string, raw-body, and multipart requests. - Direct response helpers for JSON, XML, plain-text, form-encoded, and multipart decoding, plus `contentType()` for the parameter-stripped media type. -- End-to-end response streaming via `streamRequest()`, delivering the body to a sink chunk-by-chunk with bounded memory. +- End-to-end response streaming via `stream()`, delivering the body to a sink chunk-by-chunk with bounded memory. +- `Utopia\Psr18\StreamingClientInterface` for the streaming counterpart to PSR-18; the `Adapter` interface composes it with `Psr\Http\Client\ClientInterface`, and `Utopia\Client` implements `Adapter`. - Bounded-memory request uploads on the cURL adapter, streaming the body through a read callback; `Stream\Factory::createStreamFromFile()` opens files lazily and `Stream::fromResource()` wraps a resource without copying it. - Bounded-memory multipart file uploads on both adapters via lazy `Part::file()` and the `Multipart\Body` stream: cURL streams the serialised body from disk, while Swoole streams each file with native `addFile()` (zero-copy `sendfile()`). - Typed PSR-18 exception hierarchy (`NetworkException`/`RequestException` and their subtypes) thrown by both adapters. - Immutable timeout helpers for total and connection timeouts. - Portable TLS configuration helpers — `withSslVerification()`, `withCustomCA()`, `withCertificate()`, `withMinTlsVersion()` — and the `Utopia\Client\Tls` enum. +- `withConnectionReuse()` to keep the underlying connection alive and reuse it across requests to the same origin, on both adapters (cURL persists and resets one handle; Swoole keeps a kept-alive coroutine client). +- `Utopia\Client\Pool` to borrow a reused client from a `utopia-php/pools` pool per request and reclaim it afterwards, sharing a bounded set of connections across concurrent callers. - `Utopia\Client\Decorator\Retry` decorator with a pluggable `Strategy` and a default best-practice `Backoff` strategy (idempotent methods only, transient transport failures and `429`/`502`/`503`/`504`, exponential backoff with jitter, `Retry-After` honoured). - Opt-in W3C Trace Context propagation via `withTracePropagation()` (off by default): forwards the active `utopia-php/span` trace downstream as a `traceparent` header. - `Utopia\Client\Decorator` base class for composing adapter decorators. diff --git a/README.md b/README.md index d023add..397b4f6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ HTTP `4xx` and `5xx` responses are returned, not thrown, as required by PSR-18. - [Streaming](docs/streaming.md) — consume large downloads (SSE, LLM token streams) and upload large files, both with bounded memory. - [Retries](docs/retries.md) — the `Retry` decorator and its configurable best-practice backoff strategy. -- [Configuration](docs/configuration.md) — timeouts, TLS, native cURL options, and the Swoole coroutine adapter. +- [Pooling](docs/pooling.md) — share a bounded set of reused connections across concurrent callers with `Pool`. +- [Configuration](docs/configuration.md) — timeouts, TLS, connection reuse, native cURL options, and the Swoole coroutine adapter. - [Error handling](docs/error-handling.md) — the PSR-18 exception hierarchy the adapters throw. - [Development](docs/development.md) — running the test suite and checks. diff --git a/composer.json b/composer.json index c88b246..95705af 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", + "utopia-php/pools": "^1.0", "utopia-php/span": "^1.1 || ^3.0" }, "suggest": { diff --git a/composer.lock b/composer.lock index d29870f..ddbf387 100644 --- a/composer.lock +++ b/composer.lock @@ -4,88 +4,1777 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a958c75a856a4a46ee254438ac896ef", + "content-hash": "1b07f4e8bf9c77746856fe241fc43bd4", "packages": [ + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "google/protobuf", + "version": "v4.33.6", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", + "shasum": "" + }, + "require": { + "php": ">=8.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=10.5.62 <11.0.0" + }, + "suggest": { + "ext-bcmath": "Need to support JSON deserialization" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Protobuf\\": "src/Google/Protobuf", + "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "proto library for PHP", + "homepage": "https://developers.google.com/protocol-buffers/", + "keywords": [ + "proto" + ], + "support": { + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" + }, + "time": "2026-03-18T17:32:05+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "open-telemetry/api", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", + "shasum": "" + }, + "require": { + "open-telemetry/context": "^1.4", + "php": "^8.1", + "psr/log": "^1.1|^2.0|^3.0", + "symfony/polyfill-php82": "^1.26" + }, + "conflict": { + "open-telemetry/sdk": "<=1.11" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "files": [ + "Trace/functions.php" + ], + "psr-4": { + "OpenTelemetry\\API\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "API for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "api", + "apm", + "logging", + "opentelemetry", + "otel", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-02-25T13:24:05+00:00" + }, + { + "name": "open-telemetry/context", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/context.git", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/polyfill-php82": "^1.26" + }, + "suggest": { + "ext-ffi": "To allow context switching in Fibers" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "fiber/initialize_fiber_handler.php" + ], + "psr-4": { + "OpenTelemetry\\Context\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Context implementation for OpenTelemetry PHP.", + "keywords": [ + "Context", + "opentelemetry", + "otel" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, + { + "name": "open-telemetry/exporter-otlp", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/exporter-otlp.git", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", + "shasum": "" + }, + "require": { + "open-telemetry/api": "^1.0", + "open-telemetry/gen-otlp-protobuf": "^1.1", + "open-telemetry/sdk": "^1.0", + "php": "^8.1", + "php-http/discovery": "^1.14" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "_register.php" + ], + "psr-4": { + "OpenTelemetry\\Contrib\\Otlp\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "OTLP exporter for OpenTelemetry.", + "keywords": [ + "Metrics", + "exporter", + "gRPC", + "http", + "opentelemetry", + "otel", + "otlp", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-02-05T09:44:52+00:00" + }, + { + "name": "open-telemetry/gen-otlp-protobuf", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", + "shasum": "" + }, + "require": { + "google/protobuf": "^3.22 || ^4.0", + "php": "^8.0" + }, + "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", + "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", + "keywords": [ + "Metrics", + "apm", + "gRPC", + "logging", + "opentelemetry", + "otel", + "otlp", + "protobuf", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, + { + "name": "open-telemetry/sdk", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sdk.git", + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7-server": "^1.1", + "open-telemetry/api": "^1.8", + "open-telemetry/context": "^1.4", + "open-telemetry/sem-conv": "^1.0", + "php": "^8.1", + "php-http/discovery": "^1.14", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0", + "ramsey/uuid": "^3.0 || ^4.0", + "symfony/polyfill-mbstring": "^1.23", + "symfony/polyfill-php82": "^1.26", + "tbachert/spi": "^1.0.5" + }, + "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", + "ext-mbstring": "To increase performance of string operations", + "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" + ], + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" + ], + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.12.x-dev" + } + }, + "autoload": { + "files": [ + "Common/Util/functions.php", + "Logs/Exporter/_register.php", + "Metrics/MetricExporter/_register.php", + "Propagation/_register.php", + "Trace/SpanExporter/_register.php", + "Common/Dev/Compatibility/_load.php", + "_autoload.php" + ], + "psr-4": { + "OpenTelemetry\\SDK\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "SDK for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "sdk", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-03-21T11:50:01+00:00" + }, + { + "name": "open-telemetry/sem-conv", + "version": "1.38.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sem-conv.git", + "reference": "e613bc640a407def4991b8a936a9b27edd9a3240" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", + "reference": "e613bc640a407def4991b8a936a9b27edd9a3240", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\SemConv\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Semantic conventions for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "semantic conventions", + "semconv", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-01-21T04:14:03+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + "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": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "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": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.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-13T15:52:40+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "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": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "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-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.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-03-06T13:17:50+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "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 for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "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-27T06:59:30+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/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Psr\\Http\\Client\\": "src/" + "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" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "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\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "http", - "http-client", - "psr", - "psr-18" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/php-fig/http-client" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, - "time": "2023-09-23T14:17:50+00:00" + "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-27T06:51:48+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "symfony/service-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "3.7-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -93,52 +1782,127 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/php-fig/http-factory" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, - "time": "2024-04-15T12:06:14+00:00" + "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-03-28T09:44:51+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "tbachert/spi", + "version": "v1.0.5", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/Nevay/spi.git", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "composer-plugin-api": "^2.0", + "composer/semver": "^1.0 || ^2.0 || ^3.0", + "php": "^8.1" }, - "type": "library", + "require-dev": { + "composer/composer": "^2.0", + "infection/infection": "^0.27.9", + "phpunit/phpunit": "^10.5", + "psalm/phar": "^5.18" + }, + "type": "composer-plugin", "extra": { + "class": "Nevay\\SPI\\Composer\\Plugin", "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "1.0.x-dev" + }, + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Nevay\\SPI\\": "src/" } }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Service provider loading facility", + "keywords": [ + "service provider" + ], + "support": { + "issues": "https://github.com/Nevay/spi/issues", + "source": "https://github.com/Nevay/spi/tree/v1.0.5" + }, + "time": "2025-06-29T15:42:06+00:00" + }, + { + "name": "utopia-php/pools", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/pools.git", + "reference": "19d8b82f6772a901eb1e16aa7c0e7dfba0ba711b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/19d8b82f6772a901eb1e16aa7c0e7dfba0ba711b", + "reference": "19d8b82f6772a901eb1e16aa7c0e7dfba0ba711b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "utopia-php/telemetry": "^0.4" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "11.*", + "swoole/ide-helper": "6.*" + }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Utopia\\Pools\\": "src/Pools" } }, "notification-url": "https://packagist.org/downloads/", @@ -147,24 +1911,22 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Team Appwrite", + "email": "team@appwrite.io" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "A simple library to manage connection pools", "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" + "framework", + "php", + "pools", + "utopia" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "issues": "https://github.com/utopia-php/pools/issues", + "source": "https://github.com/utopia-php/pools/tree/1.0.4" }, - "time": "2023-04-04T09:54:51+00:00" + "time": "2026-06-03T05:20:57+00:00" }, { "name": "utopia-php/span", @@ -209,6 +1971,61 @@ "source": "https://github.com/utopia-php/span/tree/3.0.1" }, "time": "2026-05-13T11:53:06+00:00" + }, + { + "name": "utopia-php/telemetry", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/telemetry.git", + "reference": "e0630df7d8176833cd4882f78814a5b893dcb0e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/e0630df7d8176833cd4882f78814a5b893dcb0e0", + "reference": "e0630df7d8176833cd4882f78814a5b893dcb0e0", + "shasum": "" + }, + "require": { + "ext-opentelemetry": "*", + "ext-protobuf": "*", + "nyholm/psr7": "1.*", + "open-telemetry/exporter-otlp": "1.*", + "open-telemetry/sdk": "1.*", + "php": ">=8.0", + "symfony/http-client": "7.*" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpbench/phpbench": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*", + "swoole/ide-helper": "6.*" + }, + "suggest": { + "ext-sockets": "Required for the Swoole transport implementation", + "ext-swoole": "Required for the Swoole transport implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Telemetry\\": "src/Telemetry" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "keywords": [ + "framework", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/telemetry/issues", + "source": "https://github.com/utopia-php/telemetry/tree/0.4.0" + }, + "time": "2026-05-29T11:57:04+00:00" } ], "packages-dev": [ diff --git a/docs/configuration.md b/docs/configuration.md index f959fa8..0aadca8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,27 @@ Peer verification is on by default. `withSslVerification(false)` disables certif $client = $client->withSslVerification(false); // insecure: disables certificate checks ``` +## Connection reuse + +Off by default, each request opens a fresh connection. `withConnectionReuse()` +keeps the underlying connection alive and reuses it for further requests to the +same origin, so the TCP/TLS handshake is paid once. + +```php +withConnectionReuse(); // or ->withConnectionReuse(false) +``` + +It maps to the right transport primitive on each adapter: the cURL adapter keeps +a single persisted handle (reset between requests, connection cache preserved), +and the Swoole adapter keeps a kept-alive coroutine client. A connection is bound +to its origin, so a request to a different host transparently gets a new one. + +Reuse is most useful when one adapter sends many requests to the same host — see +[pooling](pooling.md) for spreading a bounded set of reused connections across +concurrent callers. + ## Native cURL options Pass native cURL options with the `options` constructor argument. Options override adapter defaults when keys overlap. diff --git a/docs/pooling.md b/docs/pooling.md new file mode 100644 index 0000000..475205a --- /dev/null +++ b/docs/pooling.md @@ -0,0 +1,84 @@ +# Pooling + +`Utopia\Client\Pool` borrows a client from a [utopia-php/pools](https://github.com/utopia-php/pools) +pool for the duration of each request and reclaims it when the request completes, +so concurrent callers share a bounded set of connections instead of each opening +their own. + +```bash +composer require utopia-php/pools +``` + +```php + new Client((new CurlAdapter())->withConnectionReuse()), +)); + +$response = $pool->sendRequest($request); +$pool->stream($request, $sink); +``` + +Pair the pooled adapters with [`withConnectionReuse()`](configuration.md#connection-reuse): +without it the pool still bounds concurrency, but each borrow dials a fresh +connection, so the handshake savings are lost. + +Because `Utopia\Client` implements `Adapter` (and therefore both +`Psr\Http\Client\ClientInterface` and `Utopia\Psr18\StreamingClientInterface`), +the `init` callback can return a fully configured client — base URI, default +headers, auth, retries — so every pooled connection carries the same setup: + +```php + (new Client(new Retry((new CurlAdapter())->withConnectionReuse()))) + ->withBaseUri('https://api.example.com') + ->withBearerAuth($token), +``` + +## Swoole + +Use the `Utopia\Pools\Adapter\Swoole` pool adapter in a coroutine runtime so each +coroutine borrows a distinct connection. Run the pool inside `Coroutine\run()`, +and have `init` return a Swoole adapter with reuse enabled. + +```php + new Client((new SwooleAdapter())->withConnectionReuse()), +)); +``` + +## Notes + +- `Pool` implements `ClientInterface` and `StreamingClientInterface`, not `Adapter` + — it has no `with*()` helpers, since pooling load-balances across many + connections. Configure the connections in `init` instead. +- Connections are created lazily and on demand: a `size: 10` pool under sequential + load reuses a single connection; it only opens more when concurrent callers hold + connections at the same time. +- Reuse is per origin. A pool whose requests span many hosts re-dials on each host + change, so pooling pays off most against a single upstream. +- A stale pooled connection (dropped by the server while idle) is handled by the + transport: the cURL handle and the Swoole client both detect a dead socket and + reconnect on the next request. diff --git a/docs/retries.md b/docs/retries.md index c4e6224..ff37021 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -103,6 +103,6 @@ $retry = new Retry( `Retry` extends `Utopia\Client\Decorator`, the base for any adapter that wraps another. It forwards every configuration helper to the inner adapter and delegates -sending; a subclass overrides only `sendRequest()` / `streamRequest()`. Because each +sending; a subclass overrides only `sendRequest()` / `stream()`. Because each decorator is itself an `Adapter`, they stack in any order — for example `new Client(new Retry(new SomeOtherDecorator(new CurlAdapter())))`. diff --git a/docs/streaming.md b/docs/streaming.md index 7566fa7..3cf4d26 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,7 +2,7 @@ ## Responses -`streamRequest()` delivers the response body to a sink callback chunk-by-chunk as +`stream()` delivers the response body to a sink callback chunk-by-chunk as it arrives, so large downloads, Server-Sent Events, and LLM token streams are consumed with bounded memory — the whole body is never held at once. It returns a response carrying the status and headers; the body is empty because the body was @@ -11,7 +11,7 @@ handed to the sink. Both adapters support it. ```php streamRequest($request, function (string $chunk): void { +$response = $client->stream($request, function (string $chunk): void { echo $chunk; }); @@ -28,7 +28,7 @@ from the sink. // Parse a line-delimited (NDJSON / SSE) stream as it streams in. $buffer = ''; -$client->streamRequest($request, function (string $chunk) use (&$buffer): void { +$client->stream($request, function (string $chunk) use (&$buffer): void { $buffer .= $chunk; while (($newline = strpos($buffer, "\n")) !== false) { @@ -43,7 +43,7 @@ Notes: - Use `sendRequest()` for normal requests — it buffers the body and returns a fully decodable response (`->json()`, `->form()`, `->multipart()`). -- `streamRequest()` returns only once the stream ends. For an unbounded stream +- `stream()` returns only once the stream ends. For an unbounded stream (e.g. SSE), set the transport timeout to no-limit (`CURLOPT_TIMEOUT_MS => 0` on cURL, `timeout => -1` on Swoole) and stop by throwing from the sink. - The Swoole adapter must run inside a coroutine, like `sendRequest()`. diff --git a/src/Client.php b/src/Client.php index 3aa1a9a..b24c75f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; @@ -16,7 +15,7 @@ use Utopia\Psr7\Uri; use Utopia\Span\Span; -final class Client implements ClientInterface +final class Client implements Adapter { /** * @var array}> @@ -31,7 +30,7 @@ public function __construct( private Adapter $adapter, ) {} - public function withTimeout(float $seconds): self + public function withTimeout(float $seconds): static { $clone = clone $this; $clone->adapter = $this->adapter->withTimeout($seconds); @@ -39,7 +38,7 @@ public function withTimeout(float $seconds): self return $clone; } - public function withConnectTimeout(float $seconds): self + public function withConnectTimeout(float $seconds): static { $clone = clone $this; $clone->adapter = $this->adapter->withConnectTimeout($seconds); @@ -47,7 +46,7 @@ public function withConnectTimeout(float $seconds): self return $clone; } - public function withSslVerification(bool $enabled = true): self + public function withSslVerification(bool $enabled = true): static { $clone = clone $this; $clone->adapter = $this->adapter->withSslVerification($enabled); @@ -55,7 +54,7 @@ public function withSslVerification(bool $enabled = true): self return $clone; } - public function withCustomCA(string $path): self + public function withCustomCA(string $path): static { $clone = clone $this; $clone->adapter = $this->adapter->withCustomCA($path); @@ -63,7 +62,7 @@ public function withCustomCA(string $path): self return $clone; } - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): self + public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static { $clone = clone $this; $clone->adapter = $this->adapter->withCertificate($certPath, $keyPath, $passphrase); @@ -71,7 +70,7 @@ public function withCertificate(string $certPath, string $keyPath, ?string $pass return $clone; } - public function withMinTlsVersion(Tls $version): self + public function withMinTlsVersion(Tls $version): static { $clone = clone $this; $clone->adapter = $this->adapter->withMinTlsVersion($version); @@ -79,10 +78,18 @@ public function withMinTlsVersion(Tls $version): self return $clone; } + public function withConnectionReuse(bool $enabled = true): static + { + $clone = clone $this; + $clone->adapter = $this->adapter->withConnectionReuse($enabled); + + return $clone; + } + /** * @param array> $headers */ - public function withHeaders(array $headers): self + public function withHeaders(array $headers): static { $clone = clone $this; @@ -96,7 +103,7 @@ public function withHeaders(array $headers): self return $clone; } - public function withBaseUri(UriInterface|string $uri): self + public function withBaseUri(UriInterface|string $uri): static { $uri = $uri instanceof UriInterface ? $uri : Uri::parse($uri); @@ -110,14 +117,14 @@ public function withBaseUri(UriInterface|string $uri): self return $clone; } - public function withBasicAuth(string $username, string $password): self + public function withBasicAuth(string $username, string $password): static { return $this->withHeaders([ Header::AUTHORIZATION => 'Basic ' . base64_encode($username . ':' . $password), ]); } - public function withBearerAuth(string $token): self + public function withBearerAuth(string $token): static { return $this->withHeaders([ Header::AUTHORIZATION => 'Bearer ' . $token, @@ -128,7 +135,7 @@ public function withBearerAuth(string $token): self * Propagate the active trace downstream as a W3C Trace Context traceparent * header. Off by default; requires an active utopia-php/span span. */ - public function withTracePropagation(bool $enabled = true): self + public function withTracePropagation(bool $enabled = true): static { $clone = clone $this; $clone->tracePropagation = $enabled; @@ -152,9 +159,9 @@ public function sendRequest(RequestInterface $request): ResponseInterface * * @throws ClientExceptionInterface */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { - return $this->adapter->streamRequest($this->prepare($request), $sink); + return $this->adapter->stream($this->prepare($request), $sink); } private function prepare(RequestInterface $request): RequestInterface diff --git a/src/Client/Adapter.php b/src/Client/Adapter.php index ead9413..9946b34 100644 --- a/src/Client/Adapter.php +++ b/src/Client/Adapter.php @@ -4,12 +4,14 @@ namespace Utopia\Client; -use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use Utopia\Psr18\StreamingClientInterface; -interface Adapter extends ClientInterface +/** + * A transport that can both buffer (PSR-18) and stream responses, configured + * through immutable withX helpers. Decorators implement this too, so they compose. + */ +interface Adapter extends ClientInterface, StreamingClientInterface { public function withTimeout(float $seconds): static; @@ -24,14 +26,10 @@ public function withCertificate(string $certPath, string $keyPath, ?string $pass public function withMinTlsVersion(Tls $version): static; /** - * Send a request and pass each response body chunk to $sink as it arrives, - * keeping memory bounded regardless of body size. The returned response - * carries the status and headers; its body is empty because the body was - * delivered to $sink. - * - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface + * Reuse the underlying connection across requests to the same origin so the + * TCP/TLS handshake is paid once, rather than dialling afresh every request. + * Off by default. Each adapter maps this to its own transport: a persisted + * cURL handle, a kept-alive Swoole client, and so on. */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface; + public function withConnectionReuse(bool $enabled = true): static; } diff --git a/src/Client/Adapter/Curl/Client.php b/src/Client/Adapter/Curl/Client.php index 02858df..3d85ec5 100644 --- a/src/Client/Adapter/Curl/Client.php +++ b/src/Client/Adapter/Curl/Client.php @@ -37,6 +37,10 @@ class Client implements Adapter private readonly ResponseBuilder $responseBuilder; + private bool $reuseConnections = false; + + private ?CurlHandle $handle = null; + /** * Native cURL options. Values override adapter defaults when keys overlap. * @@ -55,6 +59,12 @@ public function __construct( $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); } + public function __clone(): void + { + // Clones get their own handle and connection cache. + $this->handle = null; + } + public function withTimeout(float $seconds): static { $clone = clone $this; @@ -114,6 +124,14 @@ public function withMinTlsVersion(Tls $version): static return $clone; } + public function withConnectionReuse(bool $enabled = true): static + { + $clone = clone $this; + $clone->reuseConnections = $enabled; + + return $clone; + } + /** * @throws ClientExceptionInterface */ @@ -139,7 +157,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface * * @throws ClientExceptionInterface */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { $parsed = $this->transfer($request, $sink); @@ -171,19 +189,13 @@ private function transfer(RequestInterface $request, callable $sink): array } $uri = $request->getUri(); - $url = (string) $uri; if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { throw new InvalidUriException($request, 'Requests must use an absolute URI.'); } $headers = ''; - $handle = curl_init($url); - - if (!$handle instanceof CurlHandle) { - throw new AdapterInitializationException($request, 'Unable to initialize curl.'); - } - + $handle = $this->handle($request); $options = $this->options($request, $headers, $sink); try { @@ -216,6 +228,33 @@ private function transfer(RequestInterface $request, callable $sink): array return $parsed; } + /** + * curl_reset() clears per-request options but keeps the handle's connection + * cache, so a kept handle reuses the socket; a fresh handle starts anew. + * + * @throws ClientExceptionInterface + */ + private function handle(RequestInterface $request): CurlHandle + { + if ($this->reuseConnections && $this->handle instanceof CurlHandle) { + curl_reset($this->handle); + + return $this->handle; + } + + $handle = curl_init(); + + if (!$handle instanceof CurlHandle) { + throw new AdapterInitializationException($request, 'Unable to initialize curl.'); + } + + if ($this->reuseConnections) { + $this->handle = $handle; + } + + return $handle; + } + /** * @param-out string $headers * @param callable(string): void $sink @@ -225,6 +264,7 @@ private function transfer(RequestInterface $request, callable $sink): array private function options(RequestInterface $request, string &$headers, callable $sink): array { $options = [ + \CURLOPT_URL => (string) $request->getUri(), \CURLOPT_CUSTOMREQUEST => $request->getMethod(), \CURLOPT_FOLLOWLOCATION => false, \CURLOPT_HEADER => false, @@ -268,7 +308,12 @@ private function options(RequestInterface $request, string &$headers, callable $ } } - return $this->options + $options; + $merged = $this->options + $options; + + // Authoritative over any caller-supplied CURLOPT_FORBID_REUSE. + $merged[\CURLOPT_FORBID_REUSE] = !$this->reuseConnections; + + return $merged; } private function milliseconds(float $seconds): int diff --git a/src/Client/Adapter/SwooleCoroutine/Client.php b/src/Client/Adapter/SwooleCoroutine/Client.php index 2698512..cbeca84 100644 --- a/src/Client/Adapter/SwooleCoroutine/Client.php +++ b/src/Client/Adapter/SwooleCoroutine/Client.php @@ -42,6 +42,8 @@ class Client implements Adapter private const string SETTING_HTTP2 = 'http2'; + private const string SETTING_KEEP_ALIVE = 'keep_alive'; + private const string SETTING_TIMEOUT = 'timeout'; private const string SETTING_SSL_VERIFY_PEER = 'ssl_verify_peer'; @@ -58,6 +60,12 @@ class Client implements Adapter private readonly ResponseBuilder $responseBuilder; + private bool $reuseConnections = false; + + private ?SwooleClient $connection = null; + + private string $connectionKey = ''; + /** * @param array $settings */ @@ -74,6 +82,13 @@ public function __construct( $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); } + public function __clone(): void + { + // Clones get their own connection; Swoole closes the dropped one on GC. + $this->connection = null; + $this->connectionKey = ''; + } + public function withTimeout(float $seconds): static { $clone = clone $this; @@ -132,6 +147,14 @@ public function withMinTlsVersion(Tls $version): static return $clone; } + public function withConnectionReuse(bool $enabled = true): static + { + $clone = clone $this; + $clone->reuseConnections = $enabled; + + return $clone; + } + /** * @throws ClientExceptionInterface */ @@ -145,7 +168,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface * * @throws ClientExceptionInterface */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { return $this->perform($request, $sink); } @@ -178,18 +201,13 @@ private function perform(RequestInterface $request, ?callable $sink): ResponseIn $this->validateSettings(); - try { - $client = new SwooleClient( - $uri->getHost(), - $this->port($request), - $uri->getScheme() === 'https', - ); - } catch (Throwable $throwable) { - throw new AdapterInitializationException($request, $throwable->getMessage(), (int) $throwable->getCode(), $throwable); - } + $client = $this->connect($request); $settings = $this->settings + [self::SETTING_HTTP2 => false]; + // Authoritative over any keep_alive passed in $settings. + $settings[self::SETTING_KEEP_ALIVE] = $this->reuseConnections; + if ($sink !== null) { $settings['write_func'] = static function (SwooleClient $cli, string $chunk) use ($sink): void { unset($cli); @@ -221,30 +239,30 @@ private function perform(RequestInterface $request, ?callable $sink): ResponseIn throw new InvalidArgumentException('Unable to configure Swoole request headers.'); } + // Swoole never clears requestBody and only clears uploadFiles after a + // successful response, so clear whichever this request omits. if ($multipart instanceof \Utopia\Psr7\Request\Multipart\Body) { + $client->requestBody = null; $this->attachMultipart($client, $multipart); } else { + $client->uploadFiles = null; $data = (string) $body; - if ($data !== '' && $client->setData($data) === false) { + if ($data === '') { + $client->requestBody = null; + } elseif ($client->setData($data) === false) { throw new InvalidArgumentException('Unable to configure Swoole request body.'); } } } catch (InvalidArgumentException $invalidArgumentException) { - $client->close(); - throw $invalidArgumentException; } catch (Throwable $throwable) { - $client->close(); - throw new InvalidArgumentException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); } try { $result = $client->execute($this->path($request)); } catch (Throwable $throwable) { - $client->close(); - throw $this->networkException($request, $throwable->getMessage(), (int) $throwable->getCode(), null, $throwable); } @@ -252,7 +270,6 @@ private function perform(RequestInterface $request, ?callable $sink): ResponseIn $message = \is_string($client->errMsg) && $client->errMsg !== '' ? $client->errMsg : 'Swoole request failed.'; $code = \is_int($client->errCode) ? $client->errCode : 0; $statusCode = $client->statusCode; - $client->close(); if ($this->isTimeout($message, $code, $statusCode)) { throw new TimeoutException($request, $message, $code); @@ -264,8 +281,6 @@ private function perform(RequestInterface $request, ?callable $sink): ResponseIn $statusCode = $client->statusCode; if (!\is_int($statusCode) || $statusCode < 100 || $statusCode > 599) { - $client->close(); - throw new InvalidResponseException($request, 'Received an invalid HTTP response.'); } @@ -281,16 +296,44 @@ private function perform(RequestInterface $request, ?callable $sink): ResponseIn $responseBody = ''; } - $response = $this->responseBuilder->build( + // Swoole keeps or closes the socket itself; never close it here. + return $this->responseBuilder->build( $statusCode, '', $this->headers($headers), $responseBody, ); + } - $client->close(); + /** + * Swoole binds a client to its origin at construction and re-checks the + * socket before each request, reconnecting if it was dropped — so a cached + * client is reused per origin and stays usable even after an error. + * + * @throws ClientExceptionInterface + */ + private function connect(RequestInterface $request): SwooleClient + { + $uri = $request->getUri(); + $secure = $uri->getScheme() === 'https'; + $key = $uri->getHost() . ':' . $this->port($request) . ':' . ($secure ? 's' : 'p'); + + if ($this->reuseConnections && $this->connection instanceof SwooleClient && $this->connectionKey === $key) { + return $this->connection; + } + + try { + $client = new SwooleClient($uri->getHost(), $this->port($request), $secure); + } catch (Throwable $throwable) { + throw new AdapterInitializationException($request, $throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + + if ($this->reuseConnections) { + $this->connection = $client; + $this->connectionKey = $key; + } - return $response; + return $client; } private function port(RequestInterface $request): int diff --git a/src/Client/Decorator.php b/src/Client/Decorator.php index c8fd775..6f1826d 100644 --- a/src/Client/Decorator.php +++ b/src/Client/Decorator.php @@ -11,7 +11,7 @@ /** * Base class for adapters that decorate another adapter. It forwards every * configuration helper to the inner adapter (returning a reconfigured clone) and - * delegates sending unchanged; subclasses override sendRequest()/streamRequest() + * delegates sending unchanged; subclasses override sendRequest()/stream() * to add behaviour. Because a decorator is itself an Adapter, decorators compose. */ abstract class Decorator implements Adapter @@ -50,6 +50,11 @@ public function withMinTlsVersion(Tls $version): static return $this->wrap($this->adapter->withMinTlsVersion($version)); } + public function withConnectionReuse(bool $enabled = true): static + { + return $this->wrap($this->adapter->withConnectionReuse($enabled)); + } + /** * @throws ClientExceptionInterface */ @@ -63,9 +68,9 @@ public function sendRequest(RequestInterface $request): ResponseInterface * * @throws ClientExceptionInterface */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { - return $this->adapter->streamRequest($request, $sink); + return $this->adapter->stream($request, $sink); } protected function wrap(Adapter $adapter): static diff --git a/src/Client/Decorator/Retry.php b/src/Client/Decorator/Retry.php index 131d3a2..b935dc8 100644 --- a/src/Client/Decorator/Retry.php +++ b/src/Client/Decorator/Retry.php @@ -62,7 +62,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface } #[\Override] - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { for ($attempt = 1; ; $attempt++) { $delivered = 0; @@ -72,7 +72,7 @@ public function streamRequest(RequestInterface $request, callable $sink): Respon }; try { - $response = $this->adapter->streamRequest($request, $countingSink); + $response = $this->adapter->stream($request, $countingSink); // Once bytes have reached the sink, replaying would duplicate them. if ($delivered > 0) { diff --git a/src/Client/Pool.php b/src/Client/Pool.php new file mode 100644 index 0000000..54d510c --- /dev/null +++ b/src/Client/Pool.php @@ -0,0 +1,50 @@ + $connections + */ + public function __construct( + private Connections $connections, + ) {} + + /** + * @throws ClientExceptionInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->connections->use( + fn(ClientInterface $client): ResponseInterface => $client->sendRequest($request), + ); + } + + /** + * @param callable(string): void $sink + * + * @throws ClientExceptionInterface + */ + public function stream(RequestInterface $request, callable $sink): ResponseInterface + { + return $this->connections->use( + fn(StreamingClientInterface $client): ResponseInterface => $client->stream($request, $sink), + ); + } +} diff --git a/src/Psr18/StreamingClientInterface.php b/src/Psr18/StreamingClientInterface.php new file mode 100644 index 0000000..7ecc289 --- /dev/null +++ b/src/Psr18/StreamingClientInterface.php @@ -0,0 +1,28 @@ +runAdapter(function () use ($client, $request, $sink, &$response, &$thrown): void { try { - $response = $client->streamRequest($request, $sink); + $response = $client->stream($request, $sink); } catch (Throwable $throwable) { $thrown = $throwable; } diff --git a/tests/Client/Decorator/RetryTest.php b/tests/Client/Decorator/RetryTest.php index d271e5f..b7725aa 100644 --- a/tests/Client/Decorator/RetryTest.php +++ b/tests/Client/Decorator/RetryTest.php @@ -115,7 +115,7 @@ function (callable $sink): ResponseInterface { $delays = []; $received = ''; - $response = $this->retry($inner, $delays)->streamRequest($request, function (string $chunk) use (&$received): void { + $response = $this->retry($inner, $delays)->stream($request, function (string $chunk) use (&$received): void { $received .= $chunk; }); @@ -138,7 +138,7 @@ function (callable $sink) use ($request): ResponseInterface { $received = ''; try { - $this->retry($inner, $delays)->streamRequest($request, function (string $chunk) use (&$received): void { + $this->retry($inner, $delays)->stream($request, function (string $chunk) use (&$received): void { $received .= $chunk; }); $this->fail('Expected the failure to be rethrown without retrying.'); @@ -224,12 +224,17 @@ public function withMinTlsVersion(Tls $version): static return $this; } + public function withConnectionReuse(bool $enabled = true): static + { + return $this; + } + public function sendRequest(RequestInterface $request): ResponseInterface { return $this->next(static function (string $chunk): void {}); } - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { return $this->next($sink); } diff --git a/tests/Client/DecoratorTest.php b/tests/Client/DecoratorTest.php index f8ed44e..160de31 100644 --- a/tests/Client/DecoratorTest.php +++ b/tests/Client/DecoratorTest.php @@ -28,7 +28,7 @@ public function testItDelegatesStreamRequestToTheInnerAdapter(): void $decorator = new PassthroughDecorator(new SwappableAdapter(200)); $received = ''; - $response = $decorator->streamRequest($this->request(), function (string $chunk) use (&$received): void { + $response = $decorator->stream($this->request(), function (string $chunk) use (&$received): void { $received .= $chunk; }); @@ -92,12 +92,17 @@ public function withMinTlsVersion(Tls $version): static return $this; } + public function withConnectionReuse(bool $enabled = true): static + { + return $this; + } + public function sendRequest(RequestInterface $request): ResponseInterface { return new Response($this->status); } - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { $sink('chunk'); diff --git a/tests/Client/PoolTest.php b/tests/Client/PoolTest.php new file mode 100644 index 0000000..527c5cc --- /dev/null +++ b/tests/Client/PoolTest.php @@ -0,0 +1,86 @@ +connections(fn(): \Utopia\Tests\Client\FakeClient => new FakeClient(200))); + + $this->assertSame(200, $pool->sendRequest($this->request())->getStatusCode()); + } + + public function testItBorrowsAConnectionToStreamARequest(): void + { + $pool = new Pool($this->connections(fn(): \Utopia\Tests\Client\FakeClient => new FakeClient(200))); + $received = ''; + + $response = $pool->stream($this->request(), function (string $chunk) use (&$received): void { + $received .= $chunk; + }); + + $this->assertSame('chunk', $received); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testItReclaimsTheConnectionSoItCanBeReused(): void + { + $created = 0; + $pool = new Pool($this->connections(function () use (&$created): FakeClient { + $created++; + + return new FakeClient(200); + }, size: 1)); + + $pool->sendRequest($this->request()); + $pool->sendRequest($this->request()); + + $this->assertSame(1, $created); + } + + /** + * @param callable(): (\Psr\Http\Client\ClientInterface&StreamingClientInterface) $init + * + * @return Connections<\Psr\Http\Client\ClientInterface&StreamingClientInterface> + */ + private function connections(callable $init, int $size = 4): Connections + { + return new Connections(new Stack(), 'test', $size, $init); + } + + private function request(): RequestInterface + { + return new Request\Factory()->createRequest(Method::GET, 'https://example.com'); + } +} + +final readonly class FakeClient implements \Psr\Http\Client\ClientInterface, StreamingClientInterface +{ + public function __construct(private int $status) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return new Response($this->status); + } + + public function stream(RequestInterface $request, callable $sink): ResponseInterface + { + $sink('chunk'); + + return new Response($this->status); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 3dd3750..36d5ebd 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -55,6 +55,17 @@ public function testItDecoratesTlsConfiguration(): void $this->assertSame('V1_2', $response->getHeaderLine('X-Tls-Min-Version')); } + public function testItDecoratesConnectionReuse(): void + { + $request = new Request\Factory()->createRequest('GET', 'https://example.com'); + $client = new Client(new RecordingAdapter()); + $configured = $client->withConnectionReuse(); + + $this->assertSame('', $client->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); + $this->assertSame('on', $configured->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); + $this->assertSame('off', $client->withConnectionReuse(false)->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); + } + public function testItRejectsInvalidTimeouts(): void { $client = new Client(new RecordingAdapter()); @@ -205,7 +216,7 @@ public function testItStreamsThroughTheAdapterApplyingBaseUriAndHeaders(): void ->withBaseUri('https://api.example.com/v1') ->withHeaders(['Accept' => 'application/json']); - $response = $client->streamRequest( + $response = $client->stream( $requestFactory->createRequest('GET', 'users'), function (string $chunk) use (&$received): void { $received .= $chunk; @@ -227,6 +238,7 @@ public function __construct( private ?string $customCA = null, private ?string $certificate = null, private ?Tls $minTlsVersion = null, + private ?bool $connectionReuse = null, ) {} public function withTimeout(float $seconds): static @@ -285,6 +297,14 @@ public function withMinTlsVersion(Tls $version): static return $clone; } + public function withConnectionReuse(bool $enabled = true): static + { + $clone = clone $this; + $clone->connectionReuse = $enabled; + + return $clone; + } + /** * @throws ClientExceptionInterface */ @@ -319,6 +339,10 @@ public function sendRequest(RequestInterface $request): ResponseInterface $response = $response->withHeader('X-Tls-Min-Version', $this->minTlsVersion->name); } + if ($this->connectionReuse !== null) { + $response = $response->withHeader('X-Connection-Reuse', $this->connectionReuse ? 'on' : 'off'); + } + if ($this->connectTimeout !== null) { return $response->withHeader('X-Connect-Timeout', (string) $this->connectTimeout); } @@ -331,7 +355,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface * * @throws ClientExceptionInterface */ - public function streamRequest(RequestInterface $request, callable $sink): ResponseInterface + public function stream(RequestInterface $request, callable $sink): ResponseInterface { $sink('chunk');