Make WordPress Core

Changeset 62037


Ignore:
Timestamp:
03/17/2026 02:28:40 PM (11 days ago)
Author:
flixos90
Message:

AI: Improve AI Client error handling.

Prior to this changeset, the WordPress AI Client would handle every exception thrown in the PHP AI Client in the same way, losing meaningful nuance about different kinds of errors along the way.

Now, the WP_Error objects returned by the WordPress AI Client come with more helpful error codes, and they include semantic HTTP status codes as well so that they can be used in relevant contexts out of the box, like e.g. the REST API.

Props flixos90, gziolo.
Fixes #64867.
See #64591.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php

    r61942 r62037  
    99
    1010use WordPress\AiClient\Builders\PromptBuilder;
     11use WordPress\AiClient\Common\Exception\InvalidArgumentException;
     12use WordPress\AiClient\Common\Exception\TokenLimitReachedException;
    1113use WordPress\AiClient\Files\DTO\File;
    1214use WordPress\AiClient\Files\Enums\FileTypeEnum;
     
    1618use WordPress\AiClient\Messages\Enums\ModalityEnum;
    1719use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
     20use WordPress\AiClient\Providers\Http\Exception\ClientException;
     21use WordPress\AiClient\Providers\Http\Exception\NetworkException;
     22use WordPress\AiClient\Providers\Http\Exception\ServerException;
    1823use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
    1924use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
     
    180185        } catch ( Exception $e ) {
    181186            $this->builder = new PromptBuilder( $registry );
    182             $this->error   = new WP_Error(
    183                 'prompt_builder_error',
    184                 $e->getMessage(),
    185                 array(
    186                     'exception_class' => get_class( $e ),
    187                 )
    188             );
     187            $this->error   = $this->exception_to_wp_error( $e );
    189188        }
    190189
     
    312311                    __( 'Prompt execution was prevented by a filter.' ),
    313312                    array(
    314                         'exception_class' => 'WP_AI_Client_Prompt_Prevented',
     313                        'status' => 503,
    315314                    )
    316315                );
     
    334333            return $result;
    335334        } catch ( Exception $e ) {
    336             $this->error = new WP_Error(
    337                 'prompt_builder_error',
    338                 $e->getMessage(),
    339                 array(
    340                     'exception_class' => get_class( $e ),
    341                 )
    342             );
     335            $this->error = $this->exception_to_wp_error( $e );
    343336
    344337            if ( self::is_generating_method( $name ) ) {
     
    347340            return $this;
    348341        }
     342    }
     343
     344    /**
     345     * Converts an exception into a WP_Error with a structured error code and message.
     346     *
     347     * This method maps different exception types to specific WP_Error codes and HTTP status codes.
     348     * The presence of the status codes means these WP_Error objects can be easily used in REST API responses
     349     * or other contexts where HTTP semantics are relevant.
     350     *
     351     * @since 7.0.0
     352     *
     353     * @param Exception $e The exception to convert.
     354     * @return WP_Error The resulting WP_Error object.
     355     */
     356    private function exception_to_wp_error( Exception $e ): WP_Error {
     357        if ( $e instanceof NetworkException ) {
     358            $error_code  = 'prompt_network_error';
     359            $status_code = 503;
     360        } elseif ( $e instanceof ClientException ) {
     361            // `ClientException` uses HTTP status codes as exception codes, so we can rely on them.
     362            $error_code  = 'prompt_client_error';
     363            $status_code = $e->getCode() ? $e->getCode() : 400;
     364        } elseif ( $e instanceof ServerException ) {
     365            // `ServerException` uses HTTP status codes as exception codes, so we can rely on them.
     366            $error_code  = 'prompt_upstream_server_error';
     367            $status_code = $e->getCode() ? $e->getCode() : 500;
     368        } elseif ( $e instanceof TokenLimitReachedException ) {
     369            $error_code  = 'prompt_token_limit_reached';
     370            $status_code = 400;
     371        } elseif ( $e instanceof InvalidArgumentException ) {
     372            $error_code  = 'prompt_invalid_argument';
     373            $status_code = 400;
     374        } else {
     375            $error_code  = 'prompt_builder_error';
     376            $status_code = 500;
     377        }
     378
     379        return new WP_Error(
     380            $error_code,
     381            $e->getMessage(),
     382            array(
     383                'status'          => $status_code,
     384                'exception_class' => get_class( $e ),
     385            )
     386        );
    349387    }
    350388
  • trunk/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php

    r61942 r62037  
    3333use WordPress\AiClient\Results\Enums\FinishReasonEnum;
    3434use WordPress\AiClient\Builders\PromptBuilder;
     35use WordPress\AiClient\Common\Exception\InvalidArgumentException as AiClientInvalidArgumentException;
     36use WordPress\AiClient\Common\Exception\TokenLimitReachedException;
     37use WordPress\AiClient\Providers\Http\Exception\ClientException;
     38use WordPress\AiClient\Providers\Http\Exception\NetworkException;
     39use WordPress\AiClient\Providers\Http\Exception\ServerException;
    3540use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
    3641use WordPress\AiClient\Tools\DTO\FunctionResponse;
     
    903908
    904909        $this->assertWPError( $result );
    905         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     910        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    906911        $this->assertStringContainsString(
    907912            'Model preferences must be model identifiers',
     
    927932
    928933        $this->assertWPError( $result );
    929         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     934        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    930935        $this->assertStringContainsString(
    931936            'Model preference tuple must contain model identifier and provider ID.',
     
    946951
    947952        $this->assertWPError( $result );
    948         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     953        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    949954        $this->assertStringContainsString(
    950955            'Model preference identifiers cannot be empty.',
     
    965970
    966971        $this->assertWPError( $result );
    967         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     972        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    968973        $this->assertStringContainsString(
    969974            'At least one model preference must be provided.',
     
    12871292
    12881293        $this->assertWPError( $result );
    1289         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1294        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    12901295        $this->assertStringContainsString( 'Cannot generate from an empty prompt', $result->get_error_message() );
    12911296    }
     
    13081313
    13091314        $this->assertWPError( $result );
    1310         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1315        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    13111316        $this->assertStringContainsString( 'The first message must be from a user role', $result->get_error_message() );
    13121317    }
     
    13411346
    13421347        $this->assertWPError( $result );
    1343         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1348        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    13441349        $this->assertStringContainsString( 'The last message must be from a user role', $result->get_error_message() );
    13451350    }
     
    13551360
    13561361        $this->assertWPError( $result );
    1357         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1362        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    13581363        $this->assertStringContainsString( 'Cannot create a message from an empty string', $result->get_error_message() );
    13591364    }
     
    13691374
    13701375        $this->assertWPError( $result );
    1371         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1376        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    13721377        $this->assertStringContainsString( 'Cannot create a message from an empty array', $result->get_error_message() );
    13731378    }
     
    13831388
    13841389        $this->assertWPError( $result );
    1385         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1390        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    13861391        $this->assertStringContainsString( 'Input must be a string, MessagePart, MessagePartArrayShape', $result->get_error_message() );
    13871392    }
     
    14031408
    14041409        $this->assertWPError( $result );
    1405         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     1410        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    14061411    }
    14071412
     
    25232528
    25242529        $this->assertWPError( $error, 'generate_text should return WP_Error when exception occurs' );
    2525         $this->assertSame( 'prompt_builder_error', $error->get_error_code() );
     2530        $this->assertSame( 'prompt_invalid_argument', $error->get_error_code() );
    25262531
    25272532        $error_data = $error->get_error_data();
     
    25982603
    25992604        $this->assertWPError( $result, 'generate_text should return WP_Error when exception occurs' );
    2600         $this->assertSame( 'prompt_builder_error', $result->get_error_code() );
     2605        $this->assertSame( 'prompt_invalid_argument', $result->get_error_code() );
    26012606        $this->assertSame( 'Model preference tuple must contain model identifier and provider ID.', $result->get_error_message() );
    26022607
     
    26062611        $this->assertNotEmpty( $error_data['exception_class'] );
    26072612    }
     2613
     2614    /**
     2615     * Invokes the private exception_to_wp_error method via reflection.
     2616     *
     2617     * @param WP_AI_Client_Prompt_Builder $builder   The builder instance.
     2618     * @param Exception                   $exception The exception to convert.
     2619     * @return WP_Error The resulting WP_Error.
     2620     */
     2621    private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Exception $exception ): WP_Error {
     2622        $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class );
     2623        $method     = $reflection->getMethod( 'exception_to_wp_error' );
     2624        self::set_accessible( $method );
     2625
     2626        return $method->invoke( $builder, $exception );
     2627    }
     2628
     2629    /**
     2630     * Tests exception_to_wp_error maps NetworkException correctly.
     2631     *
     2632     * @ticket 64591
     2633     */
     2634    public function test_exception_to_wp_error_network_exception() {
     2635        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2636        $error   = $this->invoke_exception_to_wp_error(
     2637            $builder,
     2638            new NetworkException( 'Connection timed out' )
     2639        );
     2640
     2641        $this->assertSame( 'prompt_network_error', $error->get_error_code() );
     2642        $this->assertSame( 'Connection timed out', $error->get_error_message() );
     2643        $this->assertSame( 503, $error->get_error_data()['status'] );
     2644        $this->assertSame( NetworkException::class, $error->get_error_data()['exception_class'] );
     2645    }
     2646
     2647    /**
     2648     * Tests exception_to_wp_error maps ClientException with a custom code.
     2649     *
     2650     * @ticket 64591
     2651     */
     2652    public function test_exception_to_wp_error_client_exception_with_code() {
     2653        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2654        $error   = $this->invoke_exception_to_wp_error(
     2655            $builder,
     2656            new ClientException( 'Unauthorized', 401 )
     2657        );
     2658
     2659        $this->assertSame( 'prompt_client_error', $error->get_error_code() );
     2660        $this->assertSame( 'Unauthorized', $error->get_error_message() );
     2661        $this->assertSame( 401, $error->get_error_data()['status'] );
     2662        $this->assertSame( ClientException::class, $error->get_error_data()['exception_class'] );
     2663    }
     2664
     2665    /**
     2666     * Tests exception_to_wp_error maps ClientException without a code to 400.
     2667     *
     2668     * @ticket 64591
     2669     */
     2670    public function test_exception_to_wp_error_client_exception_without_code() {
     2671        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2672        $error   = $this->invoke_exception_to_wp_error(
     2673            $builder,
     2674            new ClientException( 'Bad request' )
     2675        );
     2676
     2677        $this->assertSame( 'prompt_client_error', $error->get_error_code() );
     2678        $this->assertSame( 'Bad request', $error->get_error_message() );
     2679        $this->assertSame( 400, $error->get_error_data()['status'] );
     2680    }
     2681
     2682    /**
     2683     * Tests exception_to_wp_error maps ServerException with a custom code.
     2684     *
     2685     * @ticket 64591
     2686     */
     2687    public function test_exception_to_wp_error_server_exception_with_code() {
     2688        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2689        $error   = $this->invoke_exception_to_wp_error(
     2690            $builder,
     2691            new ServerException( 'Bad gateway', 502 )
     2692        );
     2693
     2694        $this->assertSame( 'prompt_upstream_server_error', $error->get_error_code() );
     2695        $this->assertSame( 'Bad gateway', $error->get_error_message() );
     2696        $this->assertSame( 502, $error->get_error_data()['status'] );
     2697        $this->assertSame( ServerException::class, $error->get_error_data()['exception_class'] );
     2698    }
     2699
     2700    /**
     2701     * Tests exception_to_wp_error maps ServerException without a code to 500.
     2702     *
     2703     * @ticket 64591
     2704     */
     2705    public function test_exception_to_wp_error_server_exception_without_code() {
     2706        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2707        $error   = $this->invoke_exception_to_wp_error(
     2708            $builder,
     2709            new ServerException( 'Internal server error' )
     2710        );
     2711
     2712        $this->assertSame( 'prompt_upstream_server_error', $error->get_error_code() );
     2713        $this->assertSame( 'Internal server error', $error->get_error_message() );
     2714        $this->assertSame( 500, $error->get_error_data()['status'] );
     2715    }
     2716
     2717    /**
     2718     * Tests exception_to_wp_error maps TokenLimitReachedException correctly.
     2719     *
     2720     * @ticket 64591
     2721     */
     2722    public function test_exception_to_wp_error_token_limit_reached_exception() {
     2723        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2724        $error   = $this->invoke_exception_to_wp_error(
     2725            $builder,
     2726            new TokenLimitReachedException( 'Token limit exceeded', 4096 )
     2727        );
     2728
     2729        $this->assertSame( 'prompt_token_limit_reached', $error->get_error_code() );
     2730        $this->assertSame( 'Token limit exceeded', $error->get_error_message() );
     2731        $this->assertSame( 400, $error->get_error_data()['status'] );
     2732        $this->assertSame( TokenLimitReachedException::class, $error->get_error_data()['exception_class'] );
     2733    }
     2734
     2735    /**
     2736     * Tests exception_to_wp_error maps InvalidArgumentException correctly.
     2737     *
     2738     * @ticket 64591
     2739     */
     2740    public function test_exception_to_wp_error_invalid_argument_exception() {
     2741        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2742        $error   = $this->invoke_exception_to_wp_error(
     2743            $builder,
     2744            new AiClientInvalidArgumentException( 'Invalid model parameter' )
     2745        );
     2746
     2747        $this->assertSame( 'prompt_invalid_argument', $error->get_error_code() );
     2748        $this->assertSame( 'Invalid model parameter', $error->get_error_message() );
     2749        $this->assertSame( 400, $error->get_error_data()['status'] );
     2750        $this->assertSame( AiClientInvalidArgumentException::class, $error->get_error_data()['exception_class'] );
     2751    }
     2752
     2753    /**
     2754     * Tests exception_to_wp_error maps a generic Exception to the fallback error.
     2755     *
     2756     * @ticket 64591
     2757     */
     2758    public function test_exception_to_wp_error_generic_exception() {
     2759        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2760        $error   = $this->invoke_exception_to_wp_error(
     2761            $builder,
     2762            new Exception( 'Something went wrong' )
     2763        );
     2764
     2765        $this->assertSame( 'prompt_builder_error', $error->get_error_code() );
     2766        $this->assertSame( 'Something went wrong', $error->get_error_message() );
     2767        $this->assertSame( 500, $error->get_error_data()['status'] );
     2768        $this->assertSame( 'Exception', $error->get_error_data()['exception_class'] );
     2769    }
     2770
     2771    /**
     2772     * Tests exception_to_wp_error always includes status and exception_class in error data.
     2773     *
     2774     * @ticket 64591
     2775     *
     2776     * @dataProvider data_exception_to_wp_error_error_data_structure
     2777     *
     2778     * @param Exception $exception The exception to convert.
     2779     */
     2780    public function test_exception_to_wp_error_error_data_structure( Exception $exception ) {
     2781        $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() );
     2782        $error   = $this->invoke_exception_to_wp_error( $builder, $exception );
     2783
     2784        $data = $error->get_error_data();
     2785        $this->assertIsArray( $data );
     2786        $this->assertArrayHasKey( 'status', $data );
     2787        $this->assertIsInt( $data['status'] );
     2788        $this->assertArrayHasKey( 'exception_class', $data );
     2789        $this->assertIsString( $data['exception_class'] );
     2790    }
     2791
     2792    /**
     2793     * Data provider for test_exception_to_wp_error_error_data_structure.
     2794     *
     2795     * @return array<string, array{0: Exception}>
     2796     */
     2797    public static function data_exception_to_wp_error_error_data_structure(): array {
     2798        return array(
     2799            'NetworkException'           => array( new NetworkException( 'network error' ) ),
     2800            'ClientException'            => array( new ClientException( 'client error', 422 ) ),
     2801            'ServerException'            => array( new ServerException( 'server error', 503 ) ),
     2802            'TokenLimitReachedException' => array( new TokenLimitReachedException( 'token limit' ) ),
     2803            'InvalidArgumentException'   => array( new AiClientInvalidArgumentException( 'invalid arg' ) ),
     2804            'generic Exception'          => array( new Exception( 'generic' ) ),
     2805        );
     2806    }
    26082807}
Note: See TracChangeset for help on using the changeset viewer.