Skip to content

Conversation

Ref34t
Copy link
Contributor

@Ref34t Ref34t commented Sep 1, 2025

Summary
Implements exception hierarchy to improve error handling across the WordPress AI Client ecosystem, directly addressing feedback from PR 2 in https://github.com/WordPress/wp-ai-client

Changes

New Exception Classes

  • AiClientExceptionInterface - Base interface for catch-all exception handling
  • NetworkException - HTTP transport errors, connection failures, timeouts
  • RequestException - API authentication failures, rate limiting, malformed requests
  • InvalidArgumentException - Project-scoped extension of \InvalidArgumentException
  • RuntimeException - Project-scoped extension of \RuntimeException

Updated Code

  • Updated existing exception usage in AiClient.php, GenerativeAiResult.php, FunctionResponse.php
  • Modified ResponseException to extend RequestException for better categorization
  • Added comprehensive test coverage

Benefits

Unified Exception Handling:

try {
    $result = AiClient::prompt('Generate content')->generateText();
} catch (AiClientExceptionInterface $e) {
    // Catches ALL AI Client exceptions
}

Better Error Categorization:

  • Network issues → NetworkException
  • API/Auth issues → RequestException
  • Invalid arguments → InvalidArgumentException

Related

Addresses feedback from 2 review comments about improving exception handling for wp-ai-client integration. https://github.com/WordPress/wp-ai-client

@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 1, 2025

@felixarntz This implements the exception hierarchy you suggested in PR 2 in the wp-ai-client review. Now developers can catch all AI Client exceptions with catch (AiClientExceptionInterface $e) for unified error handling, while still having specific exception types for different error categories (network, request, validation). The implementation is minimal and focused - exactly what was requested for better WP-ai-client integration.

@Ref34t Ref34t marked this pull request as ready for review September 1, 2025 13:28
@felixarntz felixarntz added the [Type] Enhancement A suggestion for improvement. label Sep 1, 2025
Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ref34t Thank you for getting this started!

A few points of feedback below, and one higher-level point here: I think we should put the exceptions into specific directories. For example:

  • InvalidArgumentException and RuntimeException into src/Common/Exception
  • NetworkException and RequestException into src/Providers/Http/Exception (where we already have ResponseException)

@JasonTheAdams It would be great to get your review as well - including regarding my own feedback.

@JasonTheAdams
Copy link
Member

I agree with @felixarntz! I like the direction, but we need to see these exceptions being used in a few places to indicate their concrete usage.

Copy link

github-actions bot commented Sep 2, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @[email protected].

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: [email protected].

Co-authored-by: felixarntz <[email protected]>
Co-authored-by: JasonTheAdams <[email protected]>
Co-authored-by: Ref34t <[email protected]>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 2, 2025

@felixarntz @JasonTheAdams
I've spent some time refactoring and improving the enhancements, you can see below what I did:

Architectural Reorganization

Moved exceptions to proper directories:

  • InvalidArgumentException & RuntimeException → src/Common/Exception/
  • NetworkException & RequestException → src/Providers/Http/Exception/
  • Updated 50+ import statements across the entire codebase

Concrete Usage Examples

  • NetworkException: PSR-18 network error handling in HttpTransporter::send()
  • RequestException: 400 Bad Request response handling in HttpTransporter::send()
  • ResponseException: Missing API data handling in OpenAiModelMetadataDirectory
  • InvalidArgumentException: 50+ validation locations throughout codebase
  • RuntimeException: 40+ operational error locations throughout codebase

Proper Inheritance

Fixed inheritance relationships:

  • RequestException extends InvalidArgumentException (for bad request data validation)
  • ResponseException extends RuntimeException (for unexpected provider responses)
  • NetworkException extends RuntimeException (for network connectivity issues)

Enhanced Developer Experience (Static factory methods suggestion)

"Simply adding static from* methods to the various *Exception classes"

RequestException (3 methods):

RequestException::fromInvalidParam('OpenAI', 'temperature', 'Must be between 0 and 2');
RequestException::fromBadRequestResponse('OpenAI', $response);
RequestException::fromBadRequestToUri($uri, 'Invalid  JSON payload');

ResponseException (4 methods):

ResponseException::fromMissingData('OpenAI', 'data');
ResponseException::fromUnexpectedStructure('OpenAI', 'array', 'string');
ResponseException::fromMalformedResponse('OpenAI', 'Invalid JSON format');
ResponseException::fromParsingFailure('OpenAI', 'model list', $jsonException);

NetworkException (5 methods):

NetworkException::fromPsr18NetworkException($uri, $networkException);
NetworkException::fromConnectionFailure($uri, 'Connection refused');
NetworkException::fromTimeout($uri, 'request', 30);
NetworkException::fromDnsFailure('api.openai.com');
NetworkException::fromSslError($uri, 'Certificate  validation failed');

Kindly review and let me know if you have any concerned

Copy link
Member

@JasonTheAdams JasonTheAdams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @Ref34t! I'm not sure I see the point in creating our own InvalidArgumentException and RuntimeException classes. Do you, @felixarntz?

@felixarntz
Copy link
Member

@JasonTheAdams I do actually. It's simply about providing classes that can implement a common interface to identify "this is a specific exception from our PHP AI Client SDK".

Consuming code can then decide whether they want to catch e.g. RuntimeException and/or InvalidArgumentException or whether they want to do a single catch-all for "known" exceptions from our SDK only by catching AiClientExceptionInterface.

@JasonTheAdams
Copy link
Member

I like that, @felixarntz! Admittedly, I hadn't noticed the common Interface. I like that idea of being able to catch exceptions thrown by this package.

As a note, I think we should update the AGENTS.md to include a note that all exceptions should use our own, including primitives, and if a primitive doesn't exist it should be created. Not sure if we should add this elsewhere, too. If we're going down this road it's important that we're consistent.

@JasonTheAdams JasonTheAdams mentioned this pull request Sep 3, 2025
20 tasks
Copy link
Member

@JasonTheAdams JasonTheAdams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tracking now, @Ref34t! Good work!

I don't think we need all these static methods. As far as I can tell, there all used only one time. If that's the case, the abstraction isn't necessary and we can just let the implementing code determine the code, message, and such.

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ref34t @JasonTheAdams I do think some of the static methods are useful - just not all of them.

Per my previous feedback, @Ref34t it would be great if you could review the codebase for where exceptions are thrown and update these exceptions to use the new exception classes and new static methods if applicable.

We should not introduce any exception classes or any static methods that aren't used anywhere - I think that's a good guiding principle. Therefore it's important that we update any existing exceptions to use the new approach, and if there are none for a specific exception or static method, we know it's not useful, at least not at this point.

@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 4, 2025

very interesting Convo Champs @felixarntz @JasonTheAdams 🥇
I'll work it this today and bring the best from it based on my idea and your thoughts

@Ref34t Ref34t force-pushed the feature/exception-hierarchy branch from 722138b to f901fce Compare September 5, 2025 16:15
@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 5, 2025

  • Updated @SInCE tags from "0.2.0" to "n.e.x.t" in all exception classes

  • Migrated 30+ throw statements across 5 core files to use custom exception classes:

    • ProviderRegistry.php: 5 exceptions converted
    • PromptBuilder.php: 25+ exceptions converted
    • Files/DTO/File.php: 5 exceptions converted
    • Http/DTO/Request.php and Response.php: exceptions converted
  • Added ResponseException::fromBadResponse() static method based on actual usage

  • Simplified ResponseUtil from 30 lines to 1 line using new static method

  • Removed 9 unused static methods across NetworkException, RequestException, and ResponseException

  • All exceptions now use project's custom classes instead of PHP built-ins

  • Maintained AiClientExceptionInterface implementation for unified error handling

  • Added exception handling requirements to AGENTS.md coding standards

@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 5, 2025

The remaining files that were updated

  1. src/Providers/Http/Traits/WithHttpTransporterTrait.php
  2. src/Providers/Http/Traits/WithRequestAuthenticationTrait.php
  3. src/ProviderImplementations/OpenAi/OpenAiProvider.php
  4. src/ProviderImplementations/Google/GoogleProvider.php
  5. src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php
  6. src/ProviderImplementations/Anthropic/AnthropicProvider.php
  7. src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php
  8. src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php
  9. src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php
  10. src/Providers/Models/DTO/ModelRequirements.php
  11. src/Providers/Models/DTO/ModelMetadata.php
  12. src/Providers/Models/DTO/ModelConfig.php
  13. src/Providers/Models/DTO/SupportedOption.php
  14. src/Providers/DTO/ProviderModelsMetadata.php
  15. src/Providers/Contracts/ProviderOperationsHandlerInterface.php
  16. src/Providers/Contracts/ProviderInterface.php
  17. src/Providers/Contracts/ModelMetadataDirectoryInterface.php
  18. src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php
  19. src/Tools/DTO/FunctionCall.php
  20. src/Results/DTO/Candidate.php
  21. src/Messages/DTO/MessagePart.php
  22. src/Common/AbstractEnum.php
  23. src/Common/AbstractDataTransferObject.php

Copy link
Member

@JasonTheAdams JasonTheAdams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! Thanks for updating all those, @Ref34t! I left a couple comments and questions.

*
* @since n.e.x.t
*/
class NetworkException extends RuntimeException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should implement \Psr\Http\Client\NetworkExceptionInterface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll accept this suggestion! Implemented all three PSR-18 interfaces with getRequest() methods and updated HttpTransporter accordingly

resolved in 04e767f

*
* @since n.e.x.t
*/
class RequestException extends InvalidArgumentException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, should implement \Psr\Http\Client\RequestExceptionInterface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved 04e767f

* @since 0.1.0
*/
class ResponseException extends Exception
class ResponseException extends RuntimeException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar, should this implement the PSR-18 ClientExceptionInterface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved 04e767f

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ref34t This looks really close now. A few last points of feedback.

@@ -0,0 +1,19 @@
<?php
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should probably live in Common\Contracts, or in Common\Exception\Contracts - not in its own separate top-level namespace.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved here a6bbaf2

throw new RuntimeException(
'Unexpected API response: Missing the data key.'
);
throw ResponseException::fromMissingData('OpenAI', 'data');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same change still needs to be made in the AnthropicModelMetadataDirectory and in GoogleModelMetadataDirectory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved here 7c742aa

Comment on lines 92 to 97
// Check for 400 Bad Request responses indicating invalid request data
if ($psr7Response->getStatusCode() === 400) {
$body = (string) $psr7Response->getBody();
$errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters';
throw RequestException::fromBadRequest($psr7Request, $errorDetail);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be handled here, because we want to make sure the caller of HttpTransporter::send is in full control over what to do with the response.

Therefore, this should be moved to ResponseUtil::throwIfNotSuccessful, so the caller can decide to use it, or not.

}

throw new ResponseException($errorMessage, $response->getStatusCode());
throw ResponseException::fromBadResponse($response);
Copy link
Member

@felixarntz felixarntz Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking that we may be want to move the fromBadResponse method from ResponseException to RequestException? Technically, the lines are blurry, but I think I have a good reason:

The only thing I personally consider a response exception is when something in the response itself is wrong (e.g. missing an expected field in a successful response), which should almost never happen. Everything else IMO is more suitable in RequestException - because if the response has an error status code, we can consider the request failed, not the response.

Would love to get your perspective on this too @JasonTheAdams!

Copy link
Member

@JasonTheAdams JasonTheAdams Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in frameworks like Symfony they distinguish this a bit differently, they use:

  • HttpExceptionInterface, which indicates a 4xx or 5xx response error
  • ClientExceptionInterface, which extends HttpExceptionInterface and is specific to 4xx response errors
  • ServerExceptionInterface, which extends HttpExceptionInterface and is specific to 5xx response errors
  • TransportExceptionInterface, which means there was a network failure

So they don't actually distinguish it in terms of "response" and "request", I think because of the ambiguity being described here. So if we like that convention better we could go that route.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to doing it that way, however none of the exceptions you mention actually does what our ResponseException here is currently covering: It's the (very likely only hypothetical) scenario where a response does not include the data we expect. This can only happen if e.g. OpenAI made a breaking change to its API response shape tomorrow and we didn't know about it. So very unlikely to ever run into it, but possible - however it's only relevant if the API response itself came back successfully.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. I'm good with RequestException, or if we want something a touch more granular: UnsupportedRequestException.

Copy link
Member

@JasonTheAdams JasonTheAdams Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! @felixarntz and I chatted a bit and landed on this suggestion:

  • RequestException - base class and thrown within Providers
  • RedirectException extends RequestException - thrown for 3xx responses
  • ClientException extends RequestException - thrown for 4xx responses
  • ServerException extends RequestException - thrown for 5xx responses
  • NetworkException extends RequestException - thrown for network errors

Sound good to you, @Ref34t? 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the final outcome you decided upon. Will try to conclude it 👍

@felixarntz
Copy link
Member

@Ref34t Note the merge conflict with ModelRequirements, it would be great if you could please fix that too now that #69 was merged.

* @param string $context Additional context about where the field was expected.
* @return self
*/
public static function fromMissingData(string $apiName, string $fieldName, string $context = ''): self
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several other places where this should be used:

  • All exceptions thrown in AbstractOpenAiCompatibleTextGenerationModel::parseResponseToGenerativeAiResult
  • All exceptions thrown in AbstractOpenAiCompatibleTextGenerationModel::parseResponseChoiceToCandidate
  • All exceptions thrown in AbstractOpenAiCompatibleTextGenerationModel::parseResponseChoiceMessageParts
  • All exceptions thrown in AbstractOpenAiCompatibleImageGenerationModel::parseResponseToGenerativeAiResult
  • All exceptions thrown in AbstractOpenAiCompatibleImageGenerationModel::parseResponseChoiceToCandidate

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some of these where it's about something more specific than a missing field, we could have another public static function fromInvalidData(string $apiName, string $message): self here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Type] Enhancement A suggestion for improvement.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants