diff --git a/src/Fetch/Concerns/ManagesConnectionPool.php b/src/Fetch/Concerns/ManagesConnectionPool.php new file mode 100644 index 0000000..652d611 --- /dev/null +++ b/src/Fetch/Concerns/ManagesConnectionPool.php @@ -0,0 +1,271 @@ +getDnsCacheTtl()); + } + } + + /** + * Configure connection pooling for this handler. + * + * @param array|bool $config Pool configuration or boolean to enable/disable + * @return $this + */ + public function withConnectionPool(array|bool $config = true): ClientHandler + { + if (is_bool($config)) { + $this->poolingEnabled = $config; + + return $this; + } + + $this->poolingEnabled = true; + + // Initialize or update the global pool + self::$connectionPool = ConnectionPool::fromArray($config); + + // Always initialize DNS cache with configuration TTL (uses default if not specified) + $poolConfig = self::$connectionPool->getConfig(); + self::$dnsCache = new DnsCache($poolConfig->getDnsCacheTtl()); + + return $this; + } + + /** + * Configure HTTP/2 for this handler. + * + * @param array|bool $config HTTP/2 configuration or boolean to enable/disable + * @return $this + */ + public function withHttp2(array|bool $config = true): ClientHandler + { + if (is_bool($config)) { + $this->http2Config = new Http2Configuration(enabled: $config); + } else { + $this->http2Config = Http2Configuration::fromArray($config); + } + + // Apply HTTP/2 curl options to the handler options + if ($this->http2Config->isEnabled()) { + $curlOptions = $this->http2Config->getCurlOptions(); + if (! empty($curlOptions)) { + $existingCurl = $this->options['curl'] ?? []; + // Use + operator to preserve integer keys (CURL constants) + // and give priority to existing options over defaults + $this->options['curl'] = $existingCurl + $curlOptions; + } + + // Set HTTP version in options + $this->options['version'] = 2.0; + } + + return $this; + } + + /** + * Get the connection pool instance. + * + * @return ConnectionPool|null The pool instance or null if not configured + */ + public function getConnectionPool(): ?ConnectionPool + { + return self::$connectionPool; + } + + /** + * Get the DNS cache instance. + * + * @return DnsCache|null The DNS cache or null if not configured + */ + public function getDnsCache(): ?DnsCache + { + return self::$dnsCache; + } + + /** + * Get the HTTP/2 configuration. + * + * @return Http2Configuration|null The HTTP/2 config or null if not configured + */ + public function getHttp2Config(): ?Http2Configuration + { + return $this->http2Config; + } + + /** + * Check if connection pooling is enabled. + */ + public function isPoolingEnabled(): bool + { + return $this->poolingEnabled && self::$connectionPool !== null && self::$connectionPool->isEnabled(); + } + + /** + * Check if HTTP/2 is enabled. + */ + public function isHttp2Enabled(): bool + { + return $this->http2Config !== null && $this->http2Config->isEnabled(); + } + + /** + * Get connection pool statistics. + * + * @return array + */ + public function getPoolStats(): array + { + if (self::$connectionPool === null) { + return ['enabled' => false]; + } + + return self::$connectionPool->getStats(); + } + + /** + * Get DNS cache statistics. + * + * @return array + */ + public function getDnsCacheStats(): array + { + if (self::$dnsCache === null) { + return ['enabled' => false]; + } + + return array_merge(['enabled' => true], self::$dnsCache->getStats()); + } + + /** + * Clear the DNS cache. + * + * @param string|null $hostname Specific hostname to clear, or null for all + * @return $this + */ + public function clearDnsCache(?string $hostname = null): ClientHandler + { + if (self::$dnsCache !== null) { + self::$dnsCache->clear($hostname); + } + + return $this; + } + + /** + * Close all pooled connections. + * + * @return $this + */ + public function closeAllConnections(): ClientHandler + { + if (self::$connectionPool !== null) { + self::$connectionPool->closeAll(); + } + + return $this; + } + + /** + * Reset the global connection pool and DNS cache. + * + * @return $this + */ + public function resetPool(): ClientHandler + { + if (self::$connectionPool !== null) { + self::$connectionPool->closeAll(); + } + self::$connectionPool = null; + self::$dnsCache = null; + $this->poolingEnabled = false; + + return $this; + } + + /** + * Get cURL options for HTTP/2 support. + * + * @return array + */ + protected function getHttp2CurlOptions(): array + { + if ($this->http2Config === null) { + return []; + } + + return $this->http2Config->getCurlOptions(); + } + + /** + * Resolve a hostname using the DNS cache. + * + * Returns null if DNS cache is not configured or if DNS resolution fails. + * This method silently catches exceptions to allow fallback behavior. + * + * @param string $hostname The hostname to resolve + * @return string|null The resolved IP address, or null if not available + */ + protected function resolveHostname(string $hostname): ?string + { + if (self::$dnsCache === null) { + return null; + } + + try { + return self::$dnsCache->resolveFirst($hostname); + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php index 29b42c5..7963adf 100644 --- a/src/Fetch/Http/ClientHandler.php +++ b/src/Fetch/Http/ClientHandler.php @@ -8,6 +8,7 @@ use Fetch\Concerns\HandlesMocking; use Fetch\Concerns\HandlesUris; use Fetch\Concerns\ManagesCache; +use Fetch\Concerns\ManagesConnectionPool; use Fetch\Concerns\ManagesDebugAndProfiling; use Fetch\Concerns\ManagesPromises; use Fetch\Concerns\ManagesRetries; @@ -31,6 +32,7 @@ class ClientHandler implements ClientHandlerInterface HandlesMocking, HandlesUris, ManagesCache, + ManagesConnectionPool, ManagesDebugAndProfiling, ManagesPromises, ManagesRetries, diff --git a/src/Fetch/Interfaces/ClientHandler.php b/src/Fetch/Interfaces/ClientHandler.php index 43a0cf4..8930c9c 100644 --- a/src/Fetch/Interfaces/ClientHandler.php +++ b/src/Fetch/Interfaces/ClientHandler.php @@ -595,4 +595,87 @@ public function getCache(): ?\Fetch\Cache\CacheInterface; * Check if caching is enabled. */ public function isCacheEnabled(): bool; + + /** + * Configure connection pooling for this handler. + * + * @param array|bool $config Pool configuration or boolean to enable/disable + * @return $this + */ + public function withConnectionPool(array|bool $config = true): self; + + /** + * Configure HTTP/2 for this handler. + * + * @param array|bool $config HTTP/2 configuration or boolean to enable/disable + * @return $this + */ + public function withHttp2(array|bool $config = true): self; + + /** + * Get the connection pool instance. + * + * @return \Fetch\Pool\ConnectionPool|null The pool instance or null if not configured + */ + public function getConnectionPool(): ?\Fetch\Pool\ConnectionPool; + + /** + * Get the DNS cache instance. + * + * @return \Fetch\Pool\DnsCache|null The DNS cache or null if not configured + */ + public function getDnsCache(): ?\Fetch\Pool\DnsCache; + + /** + * Get the HTTP/2 configuration. + * + * @return \Fetch\Pool\Http2Configuration|null The HTTP/2 config or null if not configured + */ + public function getHttp2Config(): ?\Fetch\Pool\Http2Configuration; + + /** + * Check if connection pooling is enabled. + */ + public function isPoolingEnabled(): bool; + + /** + * Check if HTTP/2 is enabled. + */ + public function isHttp2Enabled(): bool; + + /** + * Get connection pool statistics. + * + * @return array + */ + public function getPoolStats(): array; + + /** + * Get DNS cache statistics. + * + * @return array + */ + public function getDnsCacheStats(): array; + + /** + * Clear the DNS cache. + * + * @param string|null $hostname Specific hostname to clear, or null for all + * @return $this + */ + public function clearDnsCache(?string $hostname = null): self; + + /** + * Close all pooled connections. + * + * @return $this + */ + public function closeAllConnections(): self; + + /** + * Reset the global connection pool and DNS cache. + * + * @return $this + */ + public function resetPool(): self; } diff --git a/src/Fetch/Pool/Connection.php b/src/Fetch/Pool/Connection.php new file mode 100644 index 0000000..c283924 --- /dev/null +++ b/src/Fetch/Pool/Connection.php @@ -0,0 +1,201 @@ +createdAt = microtime(true); + $this->lastUsedAt = $this->createdAt; + } + + /** + * Get the host this connection is for. + */ + public function getHost(): string + { + return $this->host; + } + + /** + * Get the port number. + */ + public function getPort(): int + { + return $this->port; + } + + /** + * Check if SSL is enabled. + */ + public function isSsl(): bool + { + return $this->ssl; + } + + /** + * Get the underlying HTTP client. + */ + public function getClient(): ?ClientInterface + { + return $this->client; + } + + /** + * Set the underlying HTTP client. + * + * @param ClientInterface $client The HTTP client + * @return $this + */ + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + /** + * Get the timestamp when this connection was created. + */ + public function getCreatedAt(): float + { + return $this->createdAt; + } + + /** + * Get the timestamp when this connection was last used. + */ + public function getLastUsedAt(): float + { + return $this->lastUsedAt; + } + + /** + * Mark the connection as being used. + * + * @return $this + */ + public function markUsed(): self + { + $this->lastUsedAt = microtime(true); + + return $this; + } + + /** + * Get the number of active requests. + */ + public function getActiveRequestCount(): int + { + return $this->activeRequests; + } + + /** + * Increment the active request count. + * + * @return $this + */ + public function incrementActiveRequests(): self + { + $this->activeRequests++; + + return $this; + } + + /** + * Decrement the active request count. + * + * @return $this + */ + public function decrementActiveRequests(): self + { + if ($this->activeRequests > 0) { + $this->activeRequests--; + } + + return $this; + } + + /** + * Check if the connection is alive and usable. + */ + public function isAlive(): bool + { + return ! $this->closed && $this->client !== null; + } + + /** + * Check if the connection can be reused. + * + * @param int $keepAliveTimeout Keep-alive timeout in seconds + */ + public function isReusable(int $keepAliveTimeout = 30): bool + { + if (! $this->isAlive()) { + return false; + } + + $idleTime = microtime(true) - $this->lastUsedAt; + + return $idleTime < $keepAliveTimeout; + } + + /** + * Close the connection. + */ + public function close(): void + { + $this->closed = true; + $this->client = null; + } + + /** + * Get the connection key (host:port:ssl). + */ + public function getKey(): string + { + $scheme = $this->ssl ? 'https' : 'http'; + + return "{$scheme}://{$this->host}:{$this->port}"; + } +} diff --git a/src/Fetch/Pool/ConnectionPool.php b/src/Fetch/Pool/ConnectionPool.php new file mode 100644 index 0000000..a4f8cc5 --- /dev/null +++ b/src/Fetch/Pool/ConnectionPool.php @@ -0,0 +1,288 @@ + + */ + protected array $pools = []; + + /** + * Active connections indexed by object ID. + * + * @var array + */ + protected array $activeConnections = []; + + /** + * Connection metrics. + * + * @var array + */ + protected array $metrics = [ + 'connections_created' => 0, + 'connections_reused' => 0, + 'total_requests' => 0, + 'total_latency' => 0.0, + ]; + + /** + * Create a new connection pool manager. + * + * @param PoolConfiguration $config Pool configuration + */ + public function __construct( + protected PoolConfiguration $config, + ) {} + + /** + * Create a pool from a configuration array. + * + * @param array $config Configuration array + * @return static New pool instance + */ + public static function fromArray(array $config): static + { + return new static(PoolConfiguration::fromArray($config)); + } + + /** + * Get a connection for the specified host. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @return Connection A connection to use + */ + public function getConnection(string $host, int $port = 80, bool $ssl = false): Connection + { + $key = $this->getPoolKey($host, $port, $ssl); + + if (! isset($this->pools[$key])) { + $this->pools[$key] = new HostConnectionPool( + host: $host, + port: $port, + ssl: $ssl, + config: $this->config, + ); + } + + $connection = $this->pools[$key]->borrowConnection(); + $this->activeConnections[spl_object_id($connection)] = $connection; + + // Update metrics + $this->metrics['total_requests']++; + + return $connection; + } + + /** + * Get a connection from a URL. + * + * @param string $url The URL to connect to + * @return Connection A connection to use + */ + public function getConnectionFromUrl(string $url): Connection + { + $parsed = parse_url($url); + + $host = $parsed['host'] ?? 'localhost'; + $ssl = ($parsed['scheme'] ?? 'http') === 'https'; + $defaultPort = $ssl ? 443 : 80; + $port = $parsed['port'] ?? $defaultPort; + + return $this->getConnection($host, $port, $ssl); + } + + /** + * Release a connection back to the pool. + * + * @param Connection $connection The connection to release + */ + public function releaseConnection(Connection $connection): void + { + $id = spl_object_id($connection); + + if (isset($this->activeConnections[$id])) { + unset($this->activeConnections[$id]); + + $key = $connection->getKey(); + $poolKey = $this->normalizePoolKey($key); + + if (isset($this->pools[$poolKey])) { + $this->pools[$poolKey]->returnConnection($connection); + $this->metrics['connections_reused']++; + } else { + $connection->close(); + } + } + } + + /** + * Close a specific connection. + * + * @param Connection $connection The connection to close + */ + public function closeConnection(Connection $connection): void + { + $id = spl_object_id($connection); + + if (isset($this->activeConnections[$id])) { + unset($this->activeConnections[$id]); + } + + $connection->close(); + } + + /** + * Get an HTTP client for the specified URL. + * + * This returns the underlying Guzzle client from the pooled connection. + * + * @param string $url The URL to connect to + * @return ClientInterface|null The HTTP client or null if not available + */ + public function getClientForUrl(string $url): ?ClientInterface + { + $connection = $this->getConnectionFromUrl($url); + + return $connection->getClient(); + } + + /** + * Record connection latency for metrics. + * + * @param string $host The host + * @param int $port The port + * @param float $latency Latency in milliseconds + */ + public function recordLatency(string $host, int $port, float $latency): void + { + $this->metrics['total_latency'] += $latency; + } + + /** + * Get pool statistics. + * + * @return array + */ + public function getStats(): array + { + $stats = [ + 'enabled' => $this->config->isEnabled(), + 'total_pools' => count($this->pools), + 'active_connections' => count($this->activeConnections), + 'connections_created' => $this->metrics['connections_created'], + 'connections_reused' => $this->metrics['connections_reused'], + 'total_requests' => $this->metrics['total_requests'], + 'average_latency' => $this->calculateAverageLatency(), + 'reuse_rate' => $this->calculateReuseRate(), + 'pools' => [], + ]; + + foreach ($this->pools as $key => $pool) { + $stats['pools'][$key] = $pool->getStats(); + } + + return $stats; + } + + /** + * Check if the pool is enabled. + */ + public function isEnabled(): bool + { + return $this->config->isEnabled(); + } + + /** + * Get the pool configuration. + */ + public function getConfig(): PoolConfiguration + { + return $this->config; + } + + /** + * Close all connections in all pools. + */ + public function closeAll(): void + { + foreach ($this->activeConnections as $connection) { + $connection->close(); + } + $this->activeConnections = []; + + foreach ($this->pools as $pool) { + $pool->closeAll(); + } + $this->pools = []; + } + + /** + * Get the pool key for a host:port:ssl combination. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @return string The pool key + */ + protected function getPoolKey(string $host, int $port, bool $ssl): string + { + $scheme = $ssl ? 'https' : 'http'; + + return "{$scheme}://{$host}:{$port}"; + } + + /** + * Normalize a connection key to a pool key. + * + * @param string $key The connection key + * @return string The normalized pool key + */ + protected function normalizePoolKey(string $key): string + { + return $key; + } + + /** + * Calculate average latency across all requests. + * + * @return float Average latency in milliseconds + */ + protected function calculateAverageLatency(): float + { + $totalRequests = (int) $this->metrics['total_requests']; + if ($totalRequests === 0) { + return 0.0; + } + + return (float) $this->metrics['total_latency'] / $totalRequests; + } + + /** + * Calculate the connection reuse rate. + * + * @return float Reuse rate (0.0 to 1.0) + */ + protected function calculateReuseRate(): float + { + $totalRequests = (int) $this->metrics['total_requests']; + if ($totalRequests === 0) { + return 0.0; + } + + return (float) $this->metrics['connections_reused'] / $totalRequests; + } +} diff --git a/src/Fetch/Pool/DnsCache.php b/src/Fetch/Pool/DnsCache.php new file mode 100644 index 0000000..daabae3 --- /dev/null +++ b/src/Fetch/Pool/DnsCache.php @@ -0,0 +1,210 @@ +, expires_at: int}> + */ + protected array $cache = []; + + /** + * Create a new DNS cache instance. + * + * @param int $ttl Time-to-live for cached entries in seconds + */ + public function __construct( + protected int $ttl = 300, + ) {} + + /** + * Resolve a hostname to IP addresses. + * + * @param string $hostname The hostname to resolve + * @return array Array of IP addresses + * + * @throws NetworkException If DNS resolution fails + */ + public function resolve(string $hostname): array + { + $cacheKey = $hostname; + + // Check cache first + if (isset($this->cache[$cacheKey]) && ! $this->isExpired($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]['addresses']; + } + + // Perform DNS lookup + $addresses = $this->performDnsLookup($hostname); + + // Cache the result + $this->cache[$cacheKey] = [ + 'addresses' => $addresses, + 'expires_at' => time() + $this->ttl, + ]; + + return $addresses; + } + + /** + * Get the first resolved IP address for a hostname. + * + * @param string $hostname The hostname to resolve + * @return string The first IP address + * + * @throws NetworkException If DNS resolution fails + */ + public function resolveFirst(string $hostname): string + { + $addresses = $this->resolve($hostname); + + return $addresses[0]; + } + + /** + * Clear the cache for a specific hostname or all hostnames. + * + * @param string|null $hostname Hostname to clear, or null for all + */ + public function clear(?string $hostname = null): void + { + if ($hostname === null) { + $this->cache = []; + } else { + unset($this->cache[$hostname]); + } + } + + /** + * Get cache statistics. + * + * @return array + */ + public function getStats(): array + { + $validEntries = 0; + $expiredEntries = 0; + + foreach ($this->cache as $entry) { + if ($this->isExpired($entry)) { + $expiredEntries++; + } else { + $validEntries++; + } + } + + return [ + 'total_entries' => count($this->cache), + 'valid_entries' => $validEntries, + 'expired_entries' => $expiredEntries, + 'ttl' => $this->ttl, + ]; + } + + /** + * Set the TTL for new cache entries. + * + * @param int $ttl Time-to-live in seconds + * @return $this + */ + public function setTtl(int $ttl): self + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Prune expired entries from the cache. + * + * @return int Number of entries removed + */ + public function prune(): int + { + $removed = 0; + + foreach ($this->cache as $key => $entry) { + if ($this->isExpired($entry)) { + unset($this->cache[$key]); + $removed++; + } + } + + return $removed; + } + + /** + * Check if a cache entry is expired. + * + * @param array{addresses: array, expires_at: int} $entry Cache entry + * @return bool Whether the entry is expired + */ + protected function isExpired(array $entry): bool + { + return time() >= $entry['expires_at']; + } + + /** + * Perform actual DNS lookup. + * + * @param string $hostname The hostname to resolve + * @return array Array of IP addresses + * + * @throws NetworkException If DNS resolution fails + */ + protected function performDnsLookup(string $hostname): array + { + $addresses = []; + + // Try IPv4 (A records) + $ipv4 = @dns_get_record($hostname, DNS_A); + if ($ipv4 !== false) { + foreach ($ipv4 as $record) { + if (isset($record['ip'])) { + $addresses[] = $record['ip']; + } + } + } + + // Try IPv6 (AAAA records) + $ipv6 = @dns_get_record($hostname, DNS_AAAA); + if ($ipv6 !== false) { + foreach ($ipv6 as $record) { + if (isset($record['ipv6'])) { + $addresses[] = $record['ipv6']; + } + } + } + + // If no DNS records found, try gethostbyname + if (empty($addresses)) { + $ip = gethostbyname($hostname); + // gethostbyname returns the hostname unchanged if resolution fails + if ($ip !== $hostname) { + $addresses[] = $ip; + } + } + + if (empty($addresses)) { + // Encode hostname for use in URL (handle IDN and special characters) + $safeHost = rawurlencode($hostname); + throw new NetworkException( + "Failed to resolve hostname: {$hostname}", + new Request('GET', "https://{$safeHost}/") + ); + } + + return $addresses; + } +} diff --git a/src/Fetch/Pool/HostConnectionPool.php b/src/Fetch/Pool/HostConnectionPool.php new file mode 100644 index 0000000..c3bbc8e --- /dev/null +++ b/src/Fetch/Pool/HostConnectionPool.php @@ -0,0 +1,220 @@ + + */ + protected SplQueue $availableConnections; + + /** + * Total connections created for this host. + */ + protected int $totalCreated = 0; + + /** + * Total connections borrowed from this pool. + */ + protected int $totalBorrowed = 0; + + /** + * Total connections returned to this pool. + */ + protected int $totalReturned = 0; + + /** + * Create a new host connection pool. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @param PoolConfiguration $config Pool configuration + */ + public function __construct( + protected string $host, + protected int $port, + protected bool $ssl, + protected PoolConfiguration $config, + ) { + /** @var SplQueue $queue */ + $queue = new SplQueue; + $this->availableConnections = $queue; + + if ($this->config->isConnectionWarmupEnabled()) { + $this->warmupConnections(); + } + } + + /** + * Borrow a connection from the pool. + * + * @return Connection A connection to use + */ + public function borrowConnection(): Connection + { + $this->totalBorrowed++; + + // Try to get an existing connection from the pool + while (! $this->availableConnections->isEmpty()) { + $connection = $this->availableConnections->dequeue(); + + if ($connection->isReusable($this->config->getKeepAliveTimeout())) { + $connection->markUsed(); + $connection->incrementActiveRequests(); + + return $connection; + } + + // Connection is stale, close it + $connection->close(); + } + + // No available connections, create a new one + return $this->createConnection(); + } + + /** + * Return a connection to the pool. + * + * @param Connection $connection The connection to return + */ + public function returnConnection(Connection $connection): void + { + $this->totalReturned++; + $connection->decrementActiveRequests(); + + // Only keep connections that are still reusable and within limits + if ($connection->isReusable($this->config->getKeepAliveTimeout()) + && $this->availableConnections->count() < $this->config->getMaxIdlePerHost()) { + $this->availableConnections->enqueue($connection); + } else { + $connection->close(); + } + } + + /** + * Get the number of available connections. + */ + public function getAvailableCount(): int + { + return $this->availableConnections->count(); + } + + /** + * Get pool statistics. + * + * @return array + */ + public function getStats(): array + { + return [ + 'host' => $this->host, + 'port' => $this->port, + 'ssl' => $this->ssl, + 'available' => $this->availableConnections->count(), + 'total_created' => $this->totalCreated, + 'total_borrowed' => $this->totalBorrowed, + 'total_returned' => $this->totalReturned, + 'success_rate' => $this->totalBorrowed > 0 + ? $this->totalReturned / $this->totalBorrowed + : 1.0, + ]; + } + + /** + * Close all connections in the pool. + */ + public function closeAll(): void + { + while (! $this->availableConnections->isEmpty()) { + $connection = $this->availableConnections->dequeue(); + $connection->close(); + } + } + + /** + * Create a new connection. + * + * @return Connection The new connection + */ + protected function createConnection(): Connection + { + $this->totalCreated++; + + $connection = new Connection( + host: $this->host, + port: $this->port, + ssl: $this->ssl, + ); + + $client = $this->createHttpClient(); + $connection->setClient($client); + $connection->incrementActiveRequests(); + + return $connection; + } + + /** + * Create an HTTP client for this host. + * + * @return ClientInterface The HTTP client + */ + protected function createHttpClient(): ClientInterface + { + $scheme = $this->ssl ? 'https' : 'http'; + $baseUri = "{$scheme}://{$this->host}:{$this->port}"; + + return new GuzzleClient([ + 'base_uri' => $baseUri, + RequestOptions::CONNECT_TIMEOUT => $this->config->getConnectionTimeout(), + RequestOptions::HTTP_ERRORS => false, + // Configure TCP keep-alive for connection health monitoring + 'curl' => [ + CURLOPT_TCP_KEEPALIVE => 1, + CURLOPT_TCP_KEEPIDLE => $this->config->getKeepAliveTimeout(), + CURLOPT_TCP_KEEPINTVL => self::DEFAULT_TCP_KEEPALIVE_INTERVAL, + ], + ]); + } + + /** + * Pre-warm connections for this host. + */ + protected function warmupConnections(): void + { + $warmupCount = min( + $this->config->getWarmupConnections(), + $this->config->getMaxPerHost() + ); + + for ($i = 0; $i < $warmupCount; $i++) { + try { + $connection = $this->createConnection(); + $connection->decrementActiveRequests(); // Was incremented in createConnection + $this->availableConnections->enqueue($connection); + } catch (\Throwable) { + // Warmup failure is not critical - stop warming up but continue + break; + } + } + } +} diff --git a/src/Fetch/Pool/Http2Configuration.php b/src/Fetch/Pool/Http2Configuration.php new file mode 100644 index 0000000..93b7849 --- /dev/null +++ b/src/Fetch/Pool/Http2Configuration.php @@ -0,0 +1,163 @@ + $config Configuration array + * @return static New configuration instance + */ + public static function fromArray(array $config): static + { + return new static( + enabled: (bool) ($config['enabled'] ?? true), + maxConcurrentStreams: (int) ($config['max_concurrent_streams'] ?? self::DEFAULT_MAX_CONCURRENT_STREAMS), + windowSize: (int) ($config['window_size'] ?? self::DEFAULT_WINDOW_SIZE), + headerTableSize: (int) ($config['header_table_size'] ?? self::DEFAULT_HEADER_TABLE_SIZE), + enableServerPush: (bool) ($config['enable_server_push'] ?? false), + streamPrioritization: (bool) ($config['stream_prioritization'] ?? false), + ); + } + + /** + * Check if HTTP/2 is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get maximum concurrent streams. + */ + public function getMaxConcurrentStreams(): int + { + return $this->maxConcurrentStreams; + } + + /** + * Get window size for flow control. + */ + public function getWindowSize(): int + { + return $this->windowSize; + } + + /** + * Get header compression table size. + */ + public function getHeaderTableSize(): int + { + return $this->headerTableSize; + } + + /** + * Check if server push is enabled. + */ + public function isServerPushEnabled(): bool + { + return $this->enableServerPush; + } + + /** + * Check if stream prioritization is enabled. + */ + public function isStreamPrioritizationEnabled(): bool + { + return $this->streamPrioritization; + } + + /** + * Get cURL options for HTTP/2. + * + * @return array + */ + public function getCurlOptions(): array + { + $options = []; + + if ($this->enabled) { + // Use HTTP/2 with automatic fallback + $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; + } + + return $options; + } + + /** + * Get cURL multi options for HTTP/2 multiplexing. + * + * These options should be used with curl_multi_setopt(). + * + * @return array + */ + public function getCurlMultiOptions(): array + { + $options = []; + + if ($this->enabled && defined('CURLPIPE_MULTIPLEX')) { + $options[CURLMOPT_PIPELINING] = CURLPIPE_MULTIPLEX; + } + + return $options; + } + + /** + * Convert configuration to array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'max_concurrent_streams' => $this->maxConcurrentStreams, + 'window_size' => $this->windowSize, + 'header_table_size' => $this->headerTableSize, + 'enable_server_push' => $this->enableServerPush, + 'stream_prioritization' => $this->streamPrioritization, + ]; + } +} diff --git a/src/Fetch/Pool/PoolConfiguration.php b/src/Fetch/Pool/PoolConfiguration.php new file mode 100644 index 0000000..01965ed --- /dev/null +++ b/src/Fetch/Pool/PoolConfiguration.php @@ -0,0 +1,196 @@ + $config Configuration array + * @return static New configuration instance + */ + public static function fromArray(array $config): static + { + return new static( + enabled: (bool) ($config['enabled'] ?? true), + maxConnections: (int) ($config['max_connections'] ?? self::DEFAULT_MAX_CONNECTIONS), + maxPerHost: (int) ($config['max_per_host'] ?? self::DEFAULT_MAX_PER_HOST), + maxIdlePerHost: (int) ($config['max_idle_per_host'] ?? self::DEFAULT_MAX_IDLE_PER_HOST), + keepAliveTimeout: (int) ($config['keep_alive_timeout'] ?? self::DEFAULT_KEEP_ALIVE_TIMEOUT), + connectionTimeout: (int) ($config['connection_timeout'] ?? self::DEFAULT_CONNECTION_TIMEOUT), + strategy: (string) ($config['strategy'] ?? 'least_connections'), + connectionWarmup: (bool) ($config['connection_warmup'] ?? false), + warmupConnections: (int) ($config['warmup_connections'] ?? self::DEFAULT_WARMUP_CONNECTIONS), + dnsCacheTtl: (int) ($config['dns_cache_ttl'] ?? self::DEFAULT_DNS_CACHE_TTL), + ); + } + + /** + * Check if connection pooling is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get maximum total connections. + */ + public function getMaxConnections(): int + { + return $this->maxConnections; + } + + /** + * Get maximum connections per host. + */ + public function getMaxPerHost(): int + { + return $this->maxPerHost; + } + + /** + * Get maximum idle connections per host. + */ + public function getMaxIdlePerHost(): int + { + return $this->maxIdlePerHost; + } + + /** + * Get keep-alive timeout in seconds. + */ + public function getKeepAliveTimeout(): int + { + return $this->keepAliveTimeout; + } + + /** + * Get connection timeout in seconds. + */ + public function getConnectionTimeout(): int + { + return $this->connectionTimeout; + } + + /** + * Get connection selection strategy. + */ + public function getStrategy(): string + { + return $this->strategy; + } + + /** + * Check if connection warmup is enabled. + */ + public function isConnectionWarmupEnabled(): bool + { + return $this->connectionWarmup; + } + + /** + * Get number of connections to pre-warm. + */ + public function getWarmupConnections(): int + { + return $this->warmupConnections; + } + + /** + * Get DNS cache TTL in seconds. + */ + public function getDnsCacheTtl(): int + { + return $this->dnsCacheTtl; + } + + /** + * Convert configuration to array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'max_connections' => $this->maxConnections, + 'max_per_host' => $this->maxPerHost, + 'max_idle_per_host' => $this->maxIdlePerHost, + 'keep_alive_timeout' => $this->keepAliveTimeout, + 'connection_timeout' => $this->connectionTimeout, + 'strategy' => $this->strategy, + 'connection_warmup' => $this->connectionWarmup, + 'warmup_connections' => $this->warmupConnections, + 'dns_cache_ttl' => $this->dnsCacheTtl, + ]; + } +} diff --git a/tests/Unit/ConnectionPoolTest.php b/tests/Unit/ConnectionPoolTest.php new file mode 100644 index 0000000..fece088 --- /dev/null +++ b/tests/Unit/ConnectionPoolTest.php @@ -0,0 +1,364 @@ +assertTrue($config->isEnabled()); + $this->assertEquals(100, $config->getMaxConnections()); + $this->assertEquals(6, $config->getMaxPerHost()); + $this->assertEquals(3, $config->getMaxIdlePerHost()); + $this->assertEquals(30, $config->getKeepAliveTimeout()); + $this->assertEquals(10, $config->getConnectionTimeout()); + $this->assertEquals('least_connections', $config->getStrategy()); + $this->assertFalse($config->isConnectionWarmupEnabled()); + $this->assertEquals(0, $config->getWarmupConnections()); + $this->assertEquals(300, $config->getDnsCacheTtl()); + } + + public function test_pool_configuration_from_array(): void + { + $config = PoolConfiguration::fromArray([ + 'enabled' => true, + 'max_connections' => 50, + 'max_per_host' => 10, + 'max_idle_per_host' => 5, + 'keep_alive_timeout' => 60, + 'connection_timeout' => 15, + 'strategy' => 'round_robin', + 'connection_warmup' => true, + 'warmup_connections' => 2, + 'dns_cache_ttl' => 600, + ]); + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(50, $config->getMaxConnections()); + $this->assertEquals(10, $config->getMaxPerHost()); + $this->assertEquals(5, $config->getMaxIdlePerHost()); + $this->assertEquals(60, $config->getKeepAliveTimeout()); + $this->assertEquals(15, $config->getConnectionTimeout()); + $this->assertEquals('round_robin', $config->getStrategy()); + $this->assertTrue($config->isConnectionWarmupEnabled()); + $this->assertEquals(2, $config->getWarmupConnections()); + $this->assertEquals(600, $config->getDnsCacheTtl()); + } + + public function test_pool_configuration_to_array(): void + { + $config = new PoolConfiguration( + enabled: true, + maxConnections: 200, + maxPerHost: 8, + ); + + $array = $config->toArray(); + + $this->assertTrue($array['enabled']); + $this->assertEquals(200, $array['max_connections']); + $this->assertEquals(8, $array['max_per_host']); + } + + public function test_connection_lifecycle(): void + { + $connection = new Connection( + host: 'example.com', + port: 443, + ssl: true, + ); + + $this->assertEquals('example.com', $connection->getHost()); + $this->assertEquals(443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + $this->assertEquals('https://example.com:443', $connection->getKey()); + $this->assertEquals(0, $connection->getActiveRequestCount()); + $this->assertFalse($connection->isAlive()); // No client set yet + } + + public function test_connection_active_requests(): void + { + $connection = new Connection('example.com', 80, false); + + $this->assertEquals(0, $connection->getActiveRequestCount()); + + $connection->incrementActiveRequests(); + $this->assertEquals(1, $connection->getActiveRequestCount()); + + $connection->incrementActiveRequests(); + $this->assertEquals(2, $connection->getActiveRequestCount()); + + $connection->decrementActiveRequests(); + $this->assertEquals(1, $connection->getActiveRequestCount()); + + $connection->decrementActiveRequests(); + $connection->decrementActiveRequests(); // Should not go below 0 + $this->assertEquals(0, $connection->getActiveRequestCount()); + } + + public function test_connection_timestamps(): void + { + $before = microtime(true); + $connection = new Connection('example.com', 80, false); + $after = microtime(true); + + $this->assertGreaterThanOrEqual($before, $connection->getCreatedAt()); + $this->assertLessThanOrEqual($after, $connection->getCreatedAt()); + $this->assertEquals($connection->getCreatedAt(), $connection->getLastUsedAt()); + + sleep(1); + $connection->markUsed(); + + $this->assertGreaterThan($connection->getCreatedAt(), $connection->getLastUsedAt()); + } + + public function test_connection_close(): void + { + $connection = new Connection('example.com', 80, false); + $mockClient = $this->createMock(\GuzzleHttp\ClientInterface::class); + $connection->setClient($mockClient); + + $this->assertTrue($connection->isAlive()); + + $connection->close(); + + $this->assertFalse($connection->isAlive()); + $this->assertNull($connection->getClient()); + } + + public function test_connection_pool_get_connection(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Get a connection + $connection = $pool->getConnection('example.com', 443, true); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertEquals('example.com', $connection->getHost()); + $this->assertEquals(443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + } + + public function test_connection_pool_from_url(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $connection = $pool->getConnectionFromUrl('https://api.example.com:8443/v1/users'); + + $this->assertEquals('api.example.com', $connection->getHost()); + $this->assertEquals(8443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + } + + public function test_connection_pool_from_url_default_ports(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $httpConnection = $pool->getConnectionFromUrl('http://example.com/path'); + $this->assertEquals(80, $httpConnection->getPort()); + $this->assertFalse($httpConnection->isSsl()); + + $httpsConnection = $pool->getConnectionFromUrl('https://example.com/path'); + $this->assertEquals(443, $httpsConnection->getPort()); + $this->assertTrue($httpsConnection->isSsl()); + } + + public function test_connection_pool_release(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $connection = $pool->getConnection('example.com', 80, false); + $pool->releaseConnection($connection); + + $stats = $pool->getStats(); + $this->assertEquals(1, $stats['total_requests']); + $this->assertGreaterThanOrEqual(0, $stats['connections_reused']); + } + + public function test_connection_pool_stats(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Initial stats + $stats = $pool->getStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + + // After getting a connection + $connection = $pool->getConnection('example.com', 80, false); + $stats = $pool->getStats(); + $this->assertEquals(1, $stats['total_pools']); + $this->assertEquals(1, $stats['active_connections']); + $this->assertEquals(1, $stats['total_requests']); + } + + public function test_connection_pool_close_all(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Create some connections + $pool->getConnection('example.com', 80, false); + $pool->getConnection('api.example.com', 443, true); + + $stats = $pool->getStats(); + $this->assertEquals(2, $stats['total_pools']); + $this->assertEquals(2, $stats['active_connections']); + + $pool->closeAll(); + + $stats = $pool->getStats(); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_host_connection_pool_stats(): void + { + $config = new PoolConfiguration; + $hostPool = new HostConnectionPool( + host: 'example.com', + port: 443, + ssl: true, + config: $config, + ); + + // Initial stats + $stats = $hostPool->getStats(); + $this->assertEquals('example.com', $stats['host']); + $this->assertEquals(443, $stats['port']); + $this->assertTrue($stats['ssl']); + $this->assertEquals(0, $stats['total_borrowed']); + + // Borrow a connection + $connection = $hostPool->borrowConnection(); + $stats = $hostPool->getStats(); + $this->assertEquals(1, $stats['total_borrowed']); + $this->assertEquals(1, $stats['total_created']); + + // Return the connection + $hostPool->returnConnection($connection); + $stats = $hostPool->getStats(); + $this->assertEquals(1, $stats['total_returned']); + } + + public function test_http2_configuration_defaults(): void + { + $config = new Http2Configuration; + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(100, $config->getMaxConcurrentStreams()); + $this->assertEquals(65535, $config->getWindowSize()); + $this->assertEquals(4096, $config->getHeaderTableSize()); + $this->assertFalse($config->isServerPushEnabled()); + $this->assertFalse($config->isStreamPrioritizationEnabled()); + } + + public function test_http2_configuration_from_array(): void + { + $config = Http2Configuration::fromArray([ + 'enabled' => true, + 'max_concurrent_streams' => 200, + 'window_size' => 131070, + 'enable_server_push' => true, + 'stream_prioritization' => true, + ]); + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(200, $config->getMaxConcurrentStreams()); + $this->assertEquals(131070, $config->getWindowSize()); + $this->assertTrue($config->isServerPushEnabled()); + $this->assertTrue($config->isStreamPrioritizationEnabled()); + } + + public function test_http2_curl_options(): void + { + $config = new Http2Configuration(enabled: true); + $curlOptions = $config->getCurlOptions(); + + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $curlOptions); + $this->assertEquals(CURL_HTTP_VERSION_2_0, $curlOptions[CURLOPT_HTTP_VERSION]); + } + + public function test_http2_curl_multi_options(): void + { + $config = new Http2Configuration(enabled: true); + $curlMultiOptions = $config->getCurlMultiOptions(); + + // CURLMOPT_PIPELINING should be in multi options, not regular curl options + if (defined('CURLPIPE_MULTIPLEX')) { + $this->assertArrayHasKey(CURLMOPT_PIPELINING, $curlMultiOptions); + $this->assertEquals(CURLPIPE_MULTIPLEX, $curlMultiOptions[CURLMOPT_PIPELINING]); + } + } + + public function test_http2_disabled_curl_options(): void + { + $config = new Http2Configuration(enabled: false); + $curlOptions = $config->getCurlOptions(); + + $this->assertArrayNotHasKey(CURLOPT_HTTP_VERSION, $curlOptions); + } + + public function test_dns_cache_resolve(): void + { + $cache = new DnsCache(ttl: 300); + + // Mock the DNS lookup by testing with localhost + $addresses = $cache->resolve('localhost'); + + $this->assertNotEmpty($addresses); + } + + public function test_dns_cache_stats(): void + { + $cache = new DnsCache(ttl: 300); + + $stats = $cache->getStats(); + + $this->assertEquals(0, $stats['total_entries']); + $this->assertEquals(300, $stats['ttl']); + } + + public function test_dns_cache_clear(): void + { + $cache = new DnsCache(ttl: 300); + + // Resolve to populate cache + try { + $cache->resolve('localhost'); + } catch (\Throwable) { + // Ignore if DNS fails in test environment + } + + // Clear cache + $cache->clear(); + + $stats = $cache->getStats(); + $this->assertEquals(0, $stats['total_entries']); + } + + public function test_dns_cache_ttl_setter(): void + { + $cache = new DnsCache(ttl: 300); + $cache->setTtl(600); + + $stats = $cache->getStats(); + $this->assertEquals(600, $stats['ttl']); + } +} diff --git a/tests/Unit/ManagesConnectionPoolTest.php b/tests/Unit/ManagesConnectionPoolTest.php new file mode 100644 index 0000000..07cd2c6 --- /dev/null +++ b/tests/Unit/ManagesConnectionPoolTest.php @@ -0,0 +1,206 @@ +resetPool(); + } + + public function test_with_connection_pool_enabled(): void + { + $handler = ClientHandler::create(); + + $result = $handler->withConnectionPool(true); + + $this->assertSame($handler, $result); + // Pool is not fully enabled until configured with array + $this->assertFalse($handler->isPoolingEnabled()); + } + + public function test_with_connection_pool_config(): void + { + $handler = ClientHandler::create(); + + $handler->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 50, + 'max_per_host' => 10, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + + $pool = $handler->getConnectionPool(); + $this->assertInstanceOf(ConnectionPool::class, $pool); + $this->assertEquals(50, $pool->getConfig()->getMaxConnections()); + $this->assertEquals(10, $pool->getConfig()->getMaxPerHost()); + } + + public function test_with_http2_enabled(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2(true); + + $this->assertTrue($handler->isHttp2Enabled()); + + $config = $handler->getHttp2Config(); + $this->assertInstanceOf(Http2Configuration::class, $config); + $this->assertTrue($config->isEnabled()); + } + + public function test_with_http2_config(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2([ + 'enabled' => true, + 'max_concurrent_streams' => 200, + 'enable_server_push' => true, + ]); + + $this->assertTrue($handler->isHttp2Enabled()); + + $config = $handler->getHttp2Config(); + $this->assertEquals(200, $config->getMaxConcurrentStreams()); + $this->assertTrue($config->isServerPushEnabled()); + } + + public function test_with_http2_disabled(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2(false); + + $this->assertFalse($handler->isHttp2Enabled()); + } + + public function test_get_pool_stats(): void + { + $handler = ClientHandler::create(); + + // Before pooling is configured + $stats = $handler->getPoolStats(); + $this->assertFalse($stats['enabled']); + + // After pooling is configured + $handler->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 100, + ]); + + $stats = $handler->getPoolStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_get_dns_cache_stats(): void + { + $handler = ClientHandler::create(); + + // Before DNS cache is configured + $stats = $handler->getDnsCacheStats(); + $this->assertFalse($stats['enabled']); + + // After pooling is configured (DNS cache is initialized too) + $handler->withConnectionPool([ + 'enabled' => true, + 'dns_cache_ttl' => 600, + ]); + + $stats = $handler->getDnsCacheStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(600, $stats['ttl']); + } + + public function test_clear_dns_cache(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + 'dns_cache_ttl' => 300, + ]); + + $result = $handler->clearDnsCache(); + + $this->assertSame($handler, $result); + + $stats = $handler->getDnsCacheStats(); + $this->assertEquals(0, $stats['total_entries']); + } + + public function test_close_all_connections(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + ]); + + $result = $handler->closeAllConnections(); + + $this->assertSame($handler, $result); + + $stats = $handler->getPoolStats(); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_reset_pool(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + + $handler->resetPool(); + + $this->assertFalse($handler->isPoolingEnabled()); + $this->assertNull($handler->getConnectionPool()); + $this->assertNull($handler->getDnsCache()); + } + + public function test_http2_adds_curl_options(): void + { + $handler = ClientHandler::create(); + $handler->withHttp2(true); + + $options = $handler->getOptions(); + + $this->assertArrayHasKey('curl', $options); + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $options['curl']); + $this->assertEquals(CURL_HTTP_VERSION_2_0, $options['curl'][CURLOPT_HTTP_VERSION]); + $this->assertEquals(2.0, $options['version']); + } + + public function test_chaining_pool_and_http2_config(): void + { + $handler = ClientHandler::create() + ->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 100, + ]) + ->withHttp2([ + 'enabled' => true, + 'max_concurrent_streams' => 50, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + $this->assertTrue($handler->isHttp2Enabled()); + + $this->assertEquals(100, $handler->getConnectionPool()->getConfig()->getMaxConnections()); + $this->assertEquals(50, $handler->getHttp2Config()->getMaxConcurrentStreams()); + } +}