diff --git a/README.md b/README.md index 8c4bb32a..1eaac3d1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,22 @@ $imageFile = AiClient::prompt('Generate an illustration of the PHP elephant in t See the [`PromptBuilder` class](https://github.com/WordPress/php-ai-client/blob/trunk/src/Builders/PromptBuilder.php) and its public methods for all the ways you can configure the prompt. +### Configuring request options + +You can configure HTTP transport options like timeout and maximum redirects using the `RequestOptions` DTO: + +```php +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; + +// Set custom timeout for long-running requests +$options = new RequestOptions(120, 10); + +// Or use defaults and modify +$options = RequestOptions::defaults()->withTimeout(60); +``` + +For implementation ideas in different environments (WordPress, Guzzle, cURL), check the transporter-specific examples in the SDK source and tests. + **More documentation is coming soon.** ## Further reading diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index 2e872b76..ab016212 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -23,7 +23,8 @@ * method: string, * uri: string, * headers: array>, - * body?: string|null + * body?: string|null, + * options?: array * } * * @extends AbstractDataTransferObject @@ -34,6 +35,7 @@ class Request extends AbstractDataTransferObject public const KEY_URI = 'uri'; public const KEY_HEADERS = 'headers'; public const KEY_BODY = 'body'; + public const KEY_OPTIONS = 'options'; /** * @var HttpMethodEnum The HTTP method. @@ -60,6 +62,11 @@ class Request extends AbstractDataTransferObject */ protected ?string $body = null; + /** + * @var RequestOptions|null The request options. + */ + protected ?RequestOptions $options = null; + /** * Constructor. * @@ -69,11 +76,17 @@ class Request extends AbstractDataTransferObject * @param string $uri The request URI. * @param array> $headers The request headers. * @param string|array|null $data The request data. + * @param RequestOptions|null $options The request options. * * @throws InvalidArgumentException If the URI is empty. */ - public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null) - { + public function __construct( + HttpMethodEnum $method, + string $uri, + array $headers = [], + $data = null, + ?RequestOptions $options = null + ) { if (empty($uri)) { throw new InvalidArgumentException('URI cannot be empty.'); } @@ -81,6 +94,7 @@ public function __construct(HttpMethodEnum $method, string $uri, array $headers $this->method = $method; $this->uri = $uri; $this->headers = new HeadersCollection($headers); + $this->options = $options; // Separate data and body based on type if (is_string($data)) { @@ -281,6 +295,33 @@ public function getData(): ?array return $this->data; } + /** + * Gets the request options. + * + * @since n.e.x.t + * + * @return RequestOptions|null The request options or null if not set. + */ + public function getOptions(): ?RequestOptions + { + return $this->options; + } + + /** + * Returns a new instance with the specified options. + * + * @since n.e.x.t + * + * @param RequestOptions|null $options The request options. + * @return self A new instance with the options. + */ + public function withOptions(?RequestOptions $options): self + { + $new = clone $this; + $new->options = $options; + return $new; + } + /** * {@inheritDoc} * @@ -311,6 +352,10 @@ public static function getJsonSchema(): array 'type' => ['string'], 'description' => 'The request body.', ], + self::KEY_OPTIONS => [ + 'type' => 'object', + 'description' => 'The request options.', + ], ], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS], ]; @@ -337,6 +382,11 @@ public function toArray(): array $array[self::KEY_BODY] = $body; } + // Include options if present + if ($this->options !== null) { + $array[self::KEY_OPTIONS] = $this->options->toArray(); + } + return $array; } @@ -349,11 +399,19 @@ public static function fromArray(array $array): self { static::validateFromArrayData($array, [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]); + $options = null; + if (isset($array[self::KEY_OPTIONS]) && is_array($array[self::KEY_OPTIONS])) { + /** @var array{timeout?: int|null, max_redirects?: int|null} $optionsArray */ + $optionsArray = $array[self::KEY_OPTIONS]; + $options = RequestOptions::fromArray($optionsArray); + } + return new self( HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], - $array[self::KEY_BODY] ?? null + $array[self::KEY_BODY] ?? null, + $options ); } diff --git a/src/Providers/Http/DTO/RequestOptions.php b/src/Providers/Http/DTO/RequestOptions.php new file mode 100644 index 00000000..cb59f563 --- /dev/null +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -0,0 +1,257 @@ + + */ +class RequestOptions extends AbstractDataTransferObject +{ + public const KEY_TIMEOUT = 'timeout'; + public const KEY_MAX_REDIRECTS = 'max_redirects'; + + /** + * Default timeout in seconds. + */ + public const DEFAULT_TIMEOUT = 30; + + /** + * Default maximum number of redirects. + */ + public const DEFAULT_MAX_REDIRECTS = 5; + + /** + * Maximum allowed timeout in seconds (1 hour). + */ + public const MAX_TIMEOUT = 3600; + + /** + * Maximum allowed redirects. + */ + public const MAX_REDIRECTS = 100; + + /** + * @var int|null The request timeout in seconds. + */ + protected ?int $timeout = null; + + /** + * @var int|null The maximum number of redirects to follow. + */ + protected ?int $max_redirects = null; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param int|null $timeout The request timeout in seconds. + * @param int|null $max_redirects The maximum number of redirects to follow. + * + * @throws InvalidArgumentException If timeout or max_redirects is invalid. + */ + public function __construct(?int $timeout = null, ?int $max_redirects = null) + { + if ($timeout !== null && ($timeout < 0 || $timeout > self::MAX_TIMEOUT)) { + throw new InvalidArgumentException( + sprintf( + 'Timeout must be between 0 and %d seconds.', + self::MAX_TIMEOUT + ) + ); + } + + if ($max_redirects !== null && ($max_redirects < 0 || $max_redirects > self::MAX_REDIRECTS)) { + throw new InvalidArgumentException( + sprintf( + 'Max redirects must be between 0 and %d.', + self::MAX_REDIRECTS + ) + ); + } + + $this->timeout = $timeout; + $this->max_redirects = $max_redirects; + } + + /** + * Gets the request timeout in seconds. + * + * @since n.e.x.t + * + * @return int|null The timeout in seconds, or null if not set. + */ + public function getTimeout(): ?int + { + return $this->timeout; + } + + /** + * Gets the maximum number of redirects to follow. + * + * @since n.e.x.t + * + * @return int|null The maximum number of redirects, or null if not set. + */ + public function getMaxRedirects(): ?int + { + return $this->max_redirects; + } + + /** + * Returns a new instance with the specified timeout. + * + * @since n.e.x.t + * + * @param int|null $timeout The timeout in seconds. + * @return self A new instance with the timeout. + * + * @throws InvalidArgumentException If timeout is invalid. + */ + public function withTimeout(?int $timeout): self + { + if ($timeout !== null && ($timeout < 0 || $timeout > self::MAX_TIMEOUT)) { + throw new InvalidArgumentException( + sprintf( + 'Timeout must be between 0 and %d seconds.', + self::MAX_TIMEOUT + ) + ); + } + + $new = clone $this; + $new->timeout = $timeout; + return $new; + } + + /** + * Returns a new instance with the specified max redirects. + * + * @since n.e.x.t + * + * @param int|null $maxRedirects The maximum number of redirects. + * @return self A new instance with the max redirects. + * + * @throws InvalidArgumentException If maxRedirects is invalid. + */ + public function withMaxRedirects(?int $maxRedirects): self + { + if ($maxRedirects !== null && ($maxRedirects < 0 || $maxRedirects > self::MAX_REDIRECTS)) { + throw new InvalidArgumentException( + sprintf( + 'Max redirects must be between 0 and %d.', + self::MAX_REDIRECTS + ) + ); + } + + $new = clone $this; + $new->max_redirects = $maxRedirects; + return $new; + } + + /** + * Checks if any options are set. + * + * @since n.e.x.t + * + * @return bool True if any options are set, false otherwise. + */ + public function hasOptions(): bool + { + return $this->timeout !== null || $this->max_redirects !== null; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_TIMEOUT => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'The request timeout in seconds.', + ], + self::KEY_MAX_REDIRECTS => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'The maximum number of redirects to follow.', + ], + ], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return RequestOptionsArrayShape + */ + public function toArray(): array + { + $array = []; + + if ($this->timeout !== null) { + $array[self::KEY_TIMEOUT] = $this->timeout; + } + + if ($this->max_redirects !== null) { + $array[self::KEY_MAX_REDIRECTS] = $this->max_redirects; + } + + return $array; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @param RequestOptionsArrayShape $array + */ + public static function fromArray(array $array): self + { + return new self( + $array[self::KEY_TIMEOUT] ?? null, + $array[self::KEY_MAX_REDIRECTS] ?? null + ); + } + + /** + * Creates a RequestOptions instance with default values. + * + * @since n.e.x.t + * + * @return self A new RequestOptions instance with default values. + */ + public static function defaults(): self + { + return new self( + self::DEFAULT_TIMEOUT, + self::DEFAULT_MAX_REDIRECTS + ); + } +} diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 527cd0bd..7373b5ca 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Providers\Http\Exception; use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Utilities\ErrorMessageExtractor; @@ -32,12 +33,12 @@ class ClientException extends InvalidArgumentException * @since n.e.x.t * * @return Request - * @throws \RuntimeException If no request is available + * @throws RuntimeException If no request is available */ public function getRequest(): Request { if ($this->request === null) { - throw new \RuntimeException( + throw new RuntimeException( 'Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.' ); @@ -55,9 +56,10 @@ public function getRequest(): Request * @since n.e.x.t * * @param Response $response The HTTP response that failed. + * @param Request|null $request The originating HTTP request, if available. * @return self */ - public static function fromClientErrorResponse(Response $response): self + public static function fromClientErrorResponse(Response $response, ?Request $request = null): self { $statusCode = $response->getStatusCode(); $statusTexts = [ @@ -69,21 +71,25 @@ public static function fromClientErrorResponse(Response $response): self 429 => 'Too Many Requests', ]; - if (isset($statusTexts[$statusCode])) { - $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); - } else { - $errorMessage = sprintf( - 'Client error (%d): Request was rejected due to client-side issue', - $statusCode - ); - } + $statusText = $statusTexts[$statusCode] ?? 'Client Error'; + + $errorMessage = sprintf( + 'Client error (%d %s): Request was rejected due to client-side issue', + $statusCode, + $statusText + ); - // Extract error message from response data using centralized utility $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); if ($extractedError !== null) { $errorMessage .= ' - ' . $extractedError; } - return new self($errorMessage, $statusCode); + $exception = new self($errorMessage, $statusCode); + + if ($request !== null) { + $exception->request = $request; + } + + return $exception; } } diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 3942364f..04dafcbb 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -71,6 +71,8 @@ public function send(Request $request): Response { $psr7Request = $this->convertToPsr7Request($request); + // PSR-18 clients ignore per-request RequestOptions; custom transporters must apply them. + try { $psr7Response = $this->client->sendRequest($psr7Request); } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 18177bed..2ae72bd2 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers\Http\Util; +use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\RedirectException; @@ -29,12 +31,13 @@ class ResponseUtil * @since 0.1.0 * * @param Response $response The HTTP response to check. + * @param Request|null $request The originating HTTP request, if available. * @throws RedirectException If the response indicates a redirect (3xx). * @throws ClientException If the response indicates a client error (4xx). * @throws ServerException If the response indicates a server error (5xx). - * @throws \RuntimeException If the response has an invalid status code. + * @throws RuntimeException If the response has an invalid status code. */ - public static function throwIfNotSuccessful(Response $response): void + public static function throwIfNotSuccessful(Response $response, ?Request $request = null): void { if ($response->isSuccessful()) { return; @@ -49,7 +52,7 @@ public static function throwIfNotSuccessful(Response $response): void // 4xx Client Errors if ($statusCode >= 400 && $statusCode < 500) { - throw ClientException::fromClientErrorResponse($response); + throw ClientException::fromClientErrorResponse($response, $request); } // 5xx Server Errors @@ -57,7 +60,7 @@ public static function throwIfNotSuccessful(Response $response): void throw ServerException::fromServerErrorResponse($response); } - throw new \RuntimeException( + throw new RuntimeException( sprintf('Response returned invalid status code: %s', $response->getStatusCode()) ); } diff --git a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php new file mode 100644 index 00000000..569a9e2a --- /dev/null +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -0,0 +1,395 @@ +assertEquals(30, $options->getTimeout()); + $this->assertEquals(5, $options->getMaxRedirects()); + } + + /** + * Tests constructor with null values. + * + * @return void + */ + public function testConstructorWithNullValues(): void + { + $options = new RequestOptions(); + + $this->assertNull($options->getTimeout()); + $this->assertNull($options->getMaxRedirects()); + } + + /** + * Tests constructor with negative timeout throws exception. + * + * @return void + */ + public function testConstructorWithNegativeTimeoutThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timeout must be between 0 and'); + + new RequestOptions(-1, 5); + } + + /** + * Tests constructor with negative maxRedirects throws exception. + * + * @return void + */ + public function testConstructorWithNegativeMaxRedirectsThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Max redirects must be between 0 and'); + + new RequestOptions(30, -1); + } + + /** + * Tests constructor with timeout exceeding maximum throws exception. + * + * @return void + */ + public function testConstructorWithTimeoutExceedingMaxThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timeout must be between 0 and 3600 seconds.'); + + new RequestOptions(3601, 5); + } + + /** + * Tests constructor with maxRedirects exceeding maximum throws exception. + * + * @return void + */ + public function testConstructorWithMaxRedirectsExceedingMaxThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Max redirects must be between 0 and 100.'); + + new RequestOptions(30, 101); + } + + /** + * Tests constructor with maximum allowed values. + * + * @return void + */ + public function testConstructorWithMaximumAllowedValues(): void + { + $options = new RequestOptions( + RequestOptions::MAX_TIMEOUT, + RequestOptions::MAX_REDIRECTS + ); + + $this->assertEquals(3600, $options->getTimeout()); + $this->assertEquals(100, $options->getMaxRedirects()); + } + + /** + * Tests withTimeout method. + * + * @return void + */ + public function testWithTimeout(): void + { + $options = new RequestOptions(30, 5); + $newOptions = $options->withTimeout(60); + + $this->assertNotSame($options, $newOptions); // Ensure immutability + $this->assertEquals(30, $options->getTimeout()); + $this->assertEquals(60, $newOptions->getTimeout()); + $this->assertEquals(5, $newOptions->getMaxRedirects()); // Other properties preserved + } + + /** + * Tests withTimeout with null value. + * + * @return void + */ + public function testWithTimeoutNull(): void + { + $options = new RequestOptions(30, 5); + $newOptions = $options->withTimeout(null); + + $this->assertNull($newOptions->getTimeout()); + $this->assertEquals(5, $newOptions->getMaxRedirects()); + } + + /** + * Tests withTimeout with negative value throws exception. + * + * @return void + */ + public function testWithTimeoutNegativeThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timeout must be between 0 and'); + + $options = new RequestOptions(30, 5); + $options->withTimeout(-1); + } + + /** + * Tests withTimeout exceeding maximum throws exception. + * + * @return void + */ + public function testWithTimeoutExceedingMaxThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timeout must be between 0 and 3600 seconds.'); + + $options = new RequestOptions(30, 5); + $options->withTimeout(3601); + } + + /** + * Tests withMaxRedirects method. + * + * @return void + */ + public function testWithMaxRedirects(): void + { + $options = new RequestOptions(30, 5); + $newOptions = $options->withMaxRedirects(10); + + $this->assertNotSame($options, $newOptions); // Ensure immutability + $this->assertEquals(5, $options->getMaxRedirects()); + $this->assertEquals(10, $newOptions->getMaxRedirects()); + $this->assertEquals(30, $newOptions->getTimeout()); // Other properties preserved + } + + /** + * Tests withMaxRedirects with null value. + * + * @return void + */ + public function testWithMaxRedirectsNull(): void + { + $options = new RequestOptions(30, 5); + $newOptions = $options->withMaxRedirects(null); + + $this->assertNull($newOptions->getMaxRedirects()); + $this->assertEquals(30, $newOptions->getTimeout()); + } + + /** + * Tests withMaxRedirects with negative value throws exception. + * + * @return void + */ + public function testWithMaxRedirectsNegativeThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Max redirects must be between 0 and'); + + $options = new RequestOptions(30, 5); + $options->withMaxRedirects(-1); + } + + /** + * Tests withMaxRedirects exceeding maximum throws exception. + * + * @return void + */ + public function testWithMaxRedirectsExceedingMaxThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Max redirects must be between 0 and 100.'); + + $options = new RequestOptions(30, 5); + $options->withMaxRedirects(101); + } + + /** + * Tests hasOptions method returns true when options are set. + * + * @return void + */ + public function testHasOptionsReturnsTrueWhenOptionsSet(): void + { + $options1 = new RequestOptions(30, null); + $options2 = new RequestOptions(null, 5); + $options3 = new RequestOptions(30, 5); + + $this->assertTrue($options1->hasOptions()); + $this->assertTrue($options2->hasOptions()); + $this->assertTrue($options3->hasOptions()); + } + + /** + * Tests hasOptions method returns false when no options are set. + * + * @return void + */ + public function testHasOptionsReturnsFalseWhenNoOptionsSet(): void + { + $options = new RequestOptions(); + + $this->assertFalse($options->hasOptions()); + } + + /** + * Tests toArray method. + * + * @return void + */ + public function testToArray(): void + { + $options = new RequestOptions(30, 5); + $array = $options->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey(RequestOptions::KEY_TIMEOUT, $array); + $this->assertArrayHasKey(RequestOptions::KEY_MAX_REDIRECTS, $array); + $this->assertEquals(30, $array[RequestOptions::KEY_TIMEOUT]); + $this->assertEquals(5, $array[RequestOptions::KEY_MAX_REDIRECTS]); + } + + /** + * Tests toArray method with null values excludes them. + * + * @return void + */ + public function testToArrayExcludesNullValues(): void + { + $options = new RequestOptions(30, null); + $array = $options->toArray(); + + $this->assertArrayHasKey(RequestOptions::KEY_TIMEOUT, $array); + $this->assertArrayNotHasKey(RequestOptions::KEY_MAX_REDIRECTS, $array); + } + + /** + * Tests toArray method with all null values returns empty array. + * + * @return void + */ + public function testToArrayWithAllNullReturnsEmptyArray(): void + { + $options = new RequestOptions(); + $array = $options->toArray(); + + $this->assertIsArray($array); + $this->assertEmpty($array); + } + + /** + * Tests fromArray method. + * + * @return void + */ + public function testFromArray(): void + { + $array = [ + RequestOptions::KEY_TIMEOUT => 30, + RequestOptions::KEY_MAX_REDIRECTS => 5, + ]; + + $options = RequestOptions::fromArray($array); + + $this->assertInstanceOf(RequestOptions::class, $options); + $this->assertEquals(30, $options->getTimeout()); + $this->assertEquals(5, $options->getMaxRedirects()); + } + + /** + * Tests fromArray method with partial data. + * + * @return void + */ + public function testFromArrayWithPartialData(): void + { + $array = [ + RequestOptions::KEY_TIMEOUT => 30, + ]; + + $options = RequestOptions::fromArray($array); + + $this->assertEquals(30, $options->getTimeout()); + $this->assertNull($options->getMaxRedirects()); + } + + /** + * Tests fromArray method with empty array. + * + * @return void + */ + public function testFromArrayWithEmptyArray(): void + { + $options = RequestOptions::fromArray([]); + + $this->assertNull($options->getTimeout()); + $this->assertNull($options->getMaxRedirects()); + } + + /** + * Tests getJsonSchema method. + * + * @return void + */ + public function testGetJsonSchema(): void + { + $schema = RequestOptions::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + + // Check timeout property + $this->assertArrayHasKey(RequestOptions::KEY_TIMEOUT, $schema['properties']); + $this->assertEquals(['integer', 'null'], $schema['properties'][RequestOptions::KEY_TIMEOUT]['type']); + $this->assertEquals(0, $schema['properties'][RequestOptions::KEY_TIMEOUT]['minimum']); + + // Check max_redirects property + $this->assertArrayHasKey(RequestOptions::KEY_MAX_REDIRECTS, $schema['properties']); + $this->assertEquals(['integer', 'null'], $schema['properties'][RequestOptions::KEY_MAX_REDIRECTS]['type']); + $this->assertEquals(0, $schema['properties'][RequestOptions::KEY_MAX_REDIRECTS]['minimum']); + } + + /** + * Tests defaults method. + * + * @return void + */ + public function testDefaults(): void + { + $options = RequestOptions::defaults(); + + $this->assertEquals(RequestOptions::DEFAULT_TIMEOUT, $options->getTimeout()); + $this->assertEquals(RequestOptions::DEFAULT_MAX_REDIRECTS, $options->getMaxRedirects()); + } + + /** + * Tests default constants have correct values. + * + * @return void + */ + public function testDefaultConstants(): void + { + $this->assertEquals(30, RequestOptions::DEFAULT_TIMEOUT); + $this->assertEquals(5, RequestOptions::DEFAULT_MAX_REDIRECTS); + } +} diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index bfe366d1..c5044830 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -61,7 +61,9 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor400BadRequest(): $this->expectException(ClientException::class); $this->expectExceptionCode(400); - $this->expectExceptionMessage('Bad Request (400)'); + $this->expectExceptionMessageMatches( + '/^Client error \(400 Bad Request\): Request was rejected due to client-side issue$/' + ); ResponseUtil::throwIfNotSuccessful($response); } @@ -88,7 +90,12 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor4xxErrors( $this->expectException(ClientException::class); $this->expectExceptionCode($statusCode); $this->expectExceptionMessageMatches( - "/^[A-Za-z ]+ \\({$statusCode}\\)( - {$expectedMessagePart})?$/" + sprintf( + '/^Client error \(%d %s\): Request was rejected due to client-side issue( - %s)?$/', + $statusCode, + $this->getClientStatusText($statusCode), + $expectedMessagePart + ) ); ResponseUtil::throwIfNotSuccessful($response); @@ -115,9 +122,17 @@ public function testThrowIfNotSuccessfulThrowsServerExceptionFor5xxErrors( $this->expectException(ServerException::class); $this->expectExceptionCode($statusCode); - $this->expectExceptionMessageMatches( - "/^[A-Za-z ]+ \\({$statusCode}\\)( - {$expectedMessagePart})?$/" - ); + $statusText = $this->getServerStatusText($statusCode); + $messagePattern = $expectedMessagePart === '' + ? sprintf('/^%s \(%d\)$/', preg_quote($statusText, '/'), $statusCode) + : sprintf( + '/^%s \(%d\) - %s$/', + preg_quote($statusText, '/'), + $statusCode, + $expectedMessagePart + ); + + $this->expectExceptionMessageMatches($messagePattern); ResponseUtil::throwIfNotSuccessful($response); } @@ -168,4 +183,43 @@ public function serverErrorStatusCodeProvider(): array ], ]; } + + /** + * Maps known client status codes to the message text used in exceptions. + * + * @param int $statusCode + * @return string + */ + private function getClientStatusText(int $statusCode): string + { + $map = [ + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + ]; + + return $map[$statusCode] ?? 'Client Error'; + } + + /** + * Maps known server status codes to the message text used in exceptions. + * + * @param int $statusCode + * @return string + */ + private function getServerStatusText(int $statusCode): string + { + $map = [ + 500 => 'Internal Server Error', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 507 => 'Insufficient Storage', + ]; + + return $map[$statusCode] ?? 'Server Error'; + } } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 5b22121f..40bdb2f3 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -221,7 +221,9 @@ public function testGenerateImageResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - Invalid parameter.' + ); $model->generateImageResult($prompt); } @@ -614,7 +616,9 @@ public function testThrowIfNotSuccessfulFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Not Found (404) - The resource does not exist.'); + $this->expectExceptionMessage( + 'Client error (404 Not Found): Request was rejected due to client-side issue - The resource does not exist.' + ); $model->exposeThrowIfNotSuccessful($response); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 4c9f8b44..1f0b1fd0 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -109,7 +109,10 @@ function (string $modelId) { ); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad Request (400) - Invalid parameter provided.'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - ' + . 'Invalid parameter provided.' + ); $directory->listModelMetadata(); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 1cc9596a..2dbf1b2b 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -161,7 +161,9 @@ public function testGenerateTextResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - Invalid parameter.' + ); $model->generateTextResult($prompt); }