From 1a7be125cc90c3195125b79994d89870783ed277 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Mon, 22 Jun 2026 12:17:02 +0530 Subject: [PATCH 1/2] AI: Catch `Throwable` in `WP_AI_Client_Prompt_Builder` so `TypeError`s become `WP_Error`. `WP_AI_Client_Prompt_Builder` promises that any failure during a method chain puts the builder into an error state, surfaced as a `WP_Error` from a generating method. The constructor and the `__call()` proxy enforce this with try/catch, but both only caught `Exception`. `__call()` forwards caller arguments into the strict-typed php-ai-client SDK (`usingTemperature(float)`, `usingMaxTokens(int)`, etc.). A wrong argument type throws a `TypeError`, which extends `Error`, not `Exception`, so the existing catch missed it and the error escaped uncaught, fataling the request instead of returning a `WP_Error`. Catch `Throwable` instead of `Exception` in both locations, and widen `exception_to_wp_error()` to accept `Throwable`. Its instanceof ladder already falls through to `prompt_builder_error` / 500 for unrecognized types, so any `Error` maps cleanly with no further change. Adds regression tests covering the `TypeError` mapping and the end-to-end catch path. Props itzmekhokan. Fixes #65505. --- .../class-wp-ai-client-prompt-builder.php | 8 +-- .../ai-client/wpAiClientPromptBuilder.php | 49 ++++++++++++++++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index da7858dd76555..1e848457e7131 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -185,7 +185,7 @@ class WP_AI_Client_Prompt_Builder { public function __construct( ProviderRegistry $registry, $prompt = null ) { try { $this->builder = new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); - } catch ( Exception $e ) { + } catch ( Throwable $e ) { $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() ); $this->error = $this->exception_to_wp_error( $e ); } @@ -358,7 +358,7 @@ public function __call( string $name, array $arguments ) { } return $result; - } catch ( Exception $e ) { + } catch ( Throwable $e ) { $this->error = $this->exception_to_wp_error( $e ); if ( self::is_generating_method( $name ) ) { @@ -377,10 +377,10 @@ public function __call( string $name, array $arguments ) { * * @since 7.0.0 * - * @param Exception $e The exception to convert. + * @param Throwable $e The throwable to convert. * @return WP_Error The resulting WP_Error object. */ - private function exception_to_wp_error( Exception $e ): WP_Error { + private function exception_to_wp_error( Throwable $e ): WP_Error { if ( $e instanceof NetworkException ) { $error_code = 'prompt_network_error'; $status_code = 503; diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index e758a6868aa42..d5b3e3ad86073 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2746,10 +2746,10 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat * Invokes the private exception_to_wp_error method via reflection. * * @param WP_AI_Client_Prompt_Builder $builder The builder instance. - * @param Exception $exception The exception to convert. + * @param Throwable $exception The throwable to convert. * @return WP_Error The resulting WP_Error. */ - private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Exception $exception ): WP_Error { + private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $exception ): WP_Error { $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); $method = $reflection->getMethod( 'exception_to_wp_error' ); self::set_accessible( $method ); @@ -2899,6 +2899,51 @@ public function test_exception_to_wp_error_generic_exception() { $this->assertSame( 'Exception', $error->get_error_data()['exception_class'] ); } + /** + * Tests exception_to_wp_error maps an Error (e.g. TypeError) to a generic builder error. + * + * A TypeError extends Error, not Exception, so this guards against the + * conversion only accepting Exception instances. + * + * @ticket 65505 + */ + public function test_exception_to_wp_error_type_error() { + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + $error = $this->invoke_exception_to_wp_error( + $builder, + new TypeError( 'Argument must be of type float' ) + ); + + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + $this->assertSame( 'Argument must be of type float', $error->get_error_message() ); + $this->assertSame( 500, $error->get_error_data()['status'] ); + $this->assertSame( 'TypeError', $error->get_error_data()['exception_class'] ); + } + + /** + * Tests that a TypeError thrown by the wrapped SDK is caught and returned as a WP_Error. + * + * Passing an argument of the wrong type to a strict-typed SDK method throws a + * TypeError (which extends Error, not Exception). The builder must catch it and + * place itself in an error state instead of letting it fatal the request. + * + * @ticket 65505 + */ + public function test_call_catches_type_error_from_invalid_argument_type() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + + // usingTemperature() expects a float; an array can never be coerced and throws a TypeError. + $result = $builder->using_temperature( array( 0.7 ) ); + + // The builder is returned (fluent interface preserved), now in an error state. + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + + // A generating method now surfaces the stored WP_Error rather than fataling. + $error = $result->generate_text(); + $this->assertWPError( $error ); + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + } + /** * Tests exception_to_wp_error always includes status and exception_class in error data. * From 4a9b23100793afae600488fe2921d51698e692cb Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Thu, 25 Jun 2026 15:52:43 +0530 Subject: [PATCH 2/2] AI: Rename `exception_to_wp_error()` to `throwable_to_wp_error()`. Following the widening of the catch blocks to `Throwable`, the conversion helper and its parameter are renamed so their names reflect what they now accept. The test helper, its reflection lookup, and call sites are updated to match. Follow-up to [65505]. --- .../class-wp-ai-client-prompt-builder.php | 30 +++++++++---------- .../ai-client/wpAiClientPromptBuilder.php | 30 +++++++++---------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index 1e848457e7131..39f2338feb7b5 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -187,7 +187,7 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) { $this->builder = new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); } catch ( Throwable $e ) { $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() ); - $this->error = $this->exception_to_wp_error( $e ); + $this->error = $this->throwable_to_wp_error( $e ); } $default_timeout = 30.0; @@ -359,7 +359,7 @@ public function __call( string $name, array $arguments ) { return $result; } catch ( Throwable $e ) { - $this->error = $this->exception_to_wp_error( $e ); + $this->error = $this->throwable_to_wp_error( $e ); if ( self::is_generating_method( $name ) ) { return $this->error; @@ -369,33 +369,33 @@ public function __call( string $name, array $arguments ) { } /** - * Converts an exception into a WP_Error with a structured error code and message. + * Converts a throwable into a WP_Error with a structured error code and message. * - * This method maps different exception types to specific WP_Error codes and HTTP status codes. + * This method maps different throwable types to specific WP_Error codes and HTTP status codes. * The presence of the status codes means these WP_Error objects can be easily used in REST API responses * or other contexts where HTTP semantics are relevant. * * @since 7.0.0 * - * @param Throwable $e The throwable to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error object. */ - private function exception_to_wp_error( Throwable $e ): WP_Error { - if ( $e instanceof NetworkException ) { + private function throwable_to_wp_error( Throwable $throwable ): WP_Error { + if ( $throwable instanceof NetworkException ) { $error_code = 'prompt_network_error'; $status_code = 503; - } elseif ( $e instanceof ClientException ) { + } elseif ( $throwable instanceof ClientException ) { // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = 'prompt_client_error'; - $status_code = $e->getCode() ? $e->getCode() : 400; - } elseif ( $e instanceof ServerException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 400; + } elseif ( $throwable instanceof ServerException ) { // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = 'prompt_upstream_server_error'; - $status_code = $e->getCode() ? $e->getCode() : 500; - } elseif ( $e instanceof TokenLimitReachedException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 500; + } elseif ( $throwable instanceof TokenLimitReachedException ) { $error_code = 'prompt_token_limit_reached'; $status_code = 400; - } elseif ( $e instanceof InvalidArgumentException ) { + } elseif ( $throwable instanceof InvalidArgumentException ) { $error_code = 'prompt_invalid_argument'; $status_code = 400; } else { @@ -405,10 +405,10 @@ private function exception_to_wp_error( Throwable $e ): WP_Error { return new WP_Error( $error_code, - $e->getMessage(), + $throwable->getMessage(), array( 'status' => $status_code, - 'exception_class' => get_class( $e ), + 'exception_class' => get_class( $throwable ), ) ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index d5b3e3ad86073..31ab70485a565 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2743,18 +2743,18 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat } /** - * Invokes the private exception_to_wp_error method via reflection. + * Invokes the private throwable_to_wp_error method via reflection. * * @param WP_AI_Client_Prompt_Builder $builder The builder instance. - * @param Throwable $exception The throwable to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error. */ - private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $exception ): WP_Error { + private function invoke_throwable_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $throwable ): WP_Error { $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $method = $reflection->getMethod( 'exception_to_wp_error' ); + $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method ); - return $method->invoke( $builder, $exception ); + return $method->invoke( $builder, $throwable ); } /** @@ -2764,7 +2764,7 @@ private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $buil */ public function test_exception_to_wp_error_network_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new NetworkException( 'Connection timed out' ) ); @@ -2782,7 +2782,7 @@ public function test_exception_to_wp_error_network_exception() { */ public function test_exception_to_wp_error_client_exception_with_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ClientException( 'Unauthorized', 401 ) ); @@ -2800,7 +2800,7 @@ public function test_exception_to_wp_error_client_exception_with_code() { */ public function test_exception_to_wp_error_client_exception_without_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ClientException( 'Bad request' ) ); @@ -2817,7 +2817,7 @@ public function test_exception_to_wp_error_client_exception_without_code() { */ public function test_exception_to_wp_error_server_exception_with_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ServerException( 'Bad gateway', 502 ) ); @@ -2835,7 +2835,7 @@ public function test_exception_to_wp_error_server_exception_with_code() { */ public function test_exception_to_wp_error_server_exception_without_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ServerException( 'Internal server error' ) ); @@ -2852,7 +2852,7 @@ public function test_exception_to_wp_error_server_exception_without_code() { */ public function test_exception_to_wp_error_token_limit_reached_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new TokenLimitReachedException( 'Token limit exceeded', 4096 ) ); @@ -2870,7 +2870,7 @@ public function test_exception_to_wp_error_token_limit_reached_exception() { */ public function test_exception_to_wp_error_invalid_argument_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new AiClientInvalidArgumentException( 'Invalid model parameter' ) ); @@ -2888,7 +2888,7 @@ public function test_exception_to_wp_error_invalid_argument_exception() { */ public function test_exception_to_wp_error_generic_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new Exception( 'Something went wrong' ) ); @@ -2909,7 +2909,7 @@ public function test_exception_to_wp_error_generic_exception() { */ public function test_exception_to_wp_error_type_error() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new TypeError( 'Argument must be of type float' ) ); @@ -2955,7 +2955,7 @@ public function test_call_catches_type_error_from_invalid_argument_type() { */ public function test_exception_to_wp_error_error_data_structure( Exception $exception ) { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( $builder, $exception ); + $error = $this->invoke_throwable_to_wp_error( $builder, $exception ); $data = $error->get_error_data(); $this->assertIsArray( $data );