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.

@github-actions
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: @mohamed.khaled@9hdigital.com.

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: mohamed.khaled@9hdigital.com.

Co-authored-by: felixarntz <flixos90@git.wordpress.org>
Co-authored-by: JasonTheAdams <jason_the_adams@git.wordpress.org>
Co-authored-by: Ref34t <mokhaled@git.wordpress.org>

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 Ref34t requested a review from felixarntz September 12, 2025 06:57
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.

This is looking good to me! Thanks, @Ref34t!

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.

A few more points of follow up feedback. This is getting close though.

One question I have for both of you @Ref34t @JasonTheAdams

*
* @var RequestInterface|null
*/
private ?RequestInterface $request = null;
Copy link
Member

Choose a reason for hiding this comment

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

We should not store the lower-level PSR RequestInterface in RequestException class and its child classes - at the very least we shouldn't expose it publicly. We already have our own Request DTO for this purpose, so all public APIs should rely on that.

Can we change this accordingly here and in NetworkException please?

Copy link
Member

Choose a reason for hiding this comment

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

The only thing we'll want to be sure of, here, is that a PSR-7 RequestInterface is always converted to our Request DTO before it's passed to the exception. This is likely not an issue, but the only caveat that came to mind.

* @return RequestInterface
* @throws \RuntimeException If no request is available (when using deprecated fromBadRequestToUri)
*/
public function getRequest(): RequestInterface
Copy link
Member

Choose a reason for hiding this comment

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

See above, this should use our Request DTO instead.

Comment on lines 29 to 63
/**
* Creates a RequestException from a bad request.
*
* @since n.e.x.t
*
* @param RequestInterface $request The request that failed.
* @param string $errorDetail Details about what made the request bad.
* @return self
*/
public static function fromBadRequest(
RequestInterface $request,
string $errorDetail = 'Invalid request parameters'
): self {
$message = sprintf('Bad request to %s (400): %s', (string) $request->getUri(), $errorDetail);

$exception = new self($message);
$exception->request = $request;
return $exception;
}

/**
* Creates a RequestException from a bad request to a specific URI.
*
* @since n.e.x.t
*
* @param string $uri The URI that was requested.
* @param string $errorDetail Details about what made the request bad.
* @return self
*
* @deprecated Use fromBadRequest() with RequestInterface for PSR-18 compliance
*/
public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self
{
return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail));
}
Copy link
Member

Choose a reason for hiding this comment

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

What's the rationale for having these two methods here rather than on ClientException, which is explicitly meant for 4xx errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

got it. resolved

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

Choose a reason for hiding this comment

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

This is something @JasonTheAdams discussed but still didn't fully find a solution for: It doesn't feel right that a generic RequestException extends InvalidArgumentException. Some request exceptions fit into that (e.g. for 400 errors), others fit better to extend RuntimeException (such as 500 errors).

Brainstorming a bit: Do we even need the base RequestException class? As far as I can tell, we could have only the more specific ClientException, NetworkException, RedirectException, and ServerException, and be fine. Then ClientException could extend InvalidArgumentException while the other three could extend RuntimeException.

IMO this would clean things up nicely. What do you both think?

Copy link
Member

Choose a reason for hiding this comment

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

Ah, a couple notes here. First, I agree that this shouldn't extend InvalidArgumentException. I think it should either extend our Exception primitive or RuntimeException.

If you recall, @felixarntz, we made RequestException a class instead of an interface so that it could be used inside of Providers/Models when there isn't a status code but we are in "request" territory. Do you not think we need this anymore?

Copy link
Member

Choose a reason for hiding this comment

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

@JasonTheAdams Hmm, right. I'm not sure we need it anymore. I think it would be totally reasonable to use (our) InvalidArgumentException in cases of invalid arguments that we catch before an actual request. ClientException (for actual 4xx errors from a request) would extend InvalidArgumentException, so a developer could still catch InvalidArgumentException and be sure to get any relevant scenarios of "invalid arguments" (whether caught by our code or by the actual API).

Therefore I'm leaning towards getting rid of RequestException.

Copy link
Member

Choose a reason for hiding this comment

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

That's fine be me. Do we want to turn RequestException into an interface so folks can catch any of the subsequent exceptions easily?

Copy link
Member

Choose a reason for hiding this comment

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

I think we can leave that out for now, we can always add it later if we feel it's reasonable. I feel most people will anyway just want to catch any of our SDK's exceptions, or maybe split by InvalidArgumentException and RuntimeException, or something like that.

Mohamed Khaled added 4 commits September 25, 2025 16:40
Add ErrorMessageExtractor utility to centralize parsing of common API error response formats across exception classes. Handles standard patterns like { "error": { "message": "..." } }, { "error": "..." }, and { "message": "..." }.

Implement fromPsrRequest static factory method in Request DTO to support PSR-7 to internal DTO conversion for exception context storage.
@Ref34t
Copy link
Contributor Author

Ref34t commented Sep 26, 2025

@felixarntz Here is the summary of the iteration I did:

  1. Build centralized error message extraction utility (4314134)
  • What: Created a centralized ErrorMessageExtractor utility class
  • Purpose: Standardizes parsing of API error responses across different formats
  • Key Features:
    • Handles common patterns: {"error": {"message": "..."}}, {"error": "..."}, {"message": "..."}
    • Added fromPsrRequest factory method to Request DTO for PSR-7 conversion
  • Files: src/Providers/Http/DTO/Request.php, src/Providers/Http/Utilities/ErrorMessageExtractor.php
  1. Implement semantic exception hierarchy with proper inheritance patterns (0f18425)
  • What: Restructured exception classes with proper object-oriented inheritance
  • Key Changes:
    • Enhanced ClientException with more sophisticated error handling
    • Refactored NetworkException for better network-specific error management
    • Removed RequestException
  1. Update exception classes to use centralized error extraction and Request DTOs (2867cbc)
  • What: Integrated the new ErrorMessageExtractor utility across exception classes
  • Key Changes:
    • Simplified RedirectException, ResponseException, and ServerException
    • Reduced code duplication by using centralized error extraction
    • Leveraged Request DTOs for consistent data handling
  1. Update exception tests for new hierarchy structure (e16fba4)
  • What: Updated test suite to match the new exception hierarchy
  • Purpose: Ensured test coverage remains accurate after architectural changes
  • Files: tests/unit/Exceptions/ExceptionsTest.php

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 the iterations. A few more follow-up comments below.

Comment on lines 50 to 99
/**
* Creates a ClientException from a 400 Bad Request response.
*
* @since n.e.x.t
*
* @param string $errorDetail Details about what made the request bad.
* @return self
*/
public static function fromBadRequestResponse(string $errorDetail = 'Invalid request parameters'): self
{
$message = sprintf('Bad request (400): %s', $errorDetail);
return new self($message, 400);
}

/**
* Creates a ClientException from a bad request.
*
* @since n.e.x.t
*
* @param RequestInterface $psrRequest The PSR-7 request that failed.
* @param string $errorDetail Details about what made the request bad.
* @return self
*/
public static function fromBadRequest(
RequestInterface $psrRequest,
string $errorDetail = 'Invalid request parameters'
): self {
$request = Request::fromPsrRequest($psrRequest);
$message = sprintf('Bad request to %s (400): %s', $request->getUri(), $errorDetail);

$exception = new self($message, 400);
$exception->request = $request;
return $exception;
}

/**
* Creates a ClientException from a bad request to a specific URI.
*
* @since n.e.x.t
*
* @param string $uri The URI that was requested.
* @param string $errorDetail Details about what made the request bad.
* @return self
*
* @deprecated Use fromBadRequest() with RequestInterface for better type safety
*/
public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self
{
return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail), 400);
}
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need all these? Where are they used?

I think it would be sufficient to have the single fromClientErrorResponse below.

We can easily centralize any special handling we want to have for 400 errors, e.g. in that method you could add a check that changes the message to Bad request (400): %s instead. Even better, you could have a short map of a few important error codes and their reasons, similar to how you have in fromRedirectResponse.

On another note: Let's be consistent with the error messages - some messages here include the URI while others don't.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@felixarntz Done! Removed all unnecessary static methods in commit 7bbaadd.

Now ClientException has only fromClientErrorResponse() with focused status code mapping (400, 401, 403, 404, 422, 429) similar to RedirectException. All error messages are now consistent without URI discrepancies.

The three removed methods were unused and creating unnecessary complexity. Thanks for the simplification guidance!

Copy link
Member

Choose a reason for hiding this comment

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

One follow up feedback below, I think this is still not the message format I suggested above.

@Ref34t Ref34t requested a review from felixarntz September 29, 2025 20:55
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 the iterations, this looks pretty much good to go!

Just a few last points, but those are quick to address, so I can do that inline and merge.

429 => 'Too Many Requests',
];

$statusText = $statusTexts[$statusCode] ?? 'Client Error';
Copy link
Member

Choose a reason for hiding this comment

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

With this, it can lead to some weird messages like this: Client error (405 Client Error): Request was rejected due to client-side issue

Instead of this, can we change the message completely depending on whether a specific status text is set or not?

Something like:

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);
}

308 => 'Permanent Redirect',
];

$statusText = $statusTexts[$statusCode] ?? 'Redirect';
Copy link
Member

Choose a reason for hiding this comment

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

Can we do the same approach here that I suggested above in ClientException?

507 => 'Insufficient Storage',
];

$statusText = $statusTexts[$statusCode] ?? 'Server Error';
Copy link
Member

Choose a reason for hiding this comment

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

And same here.

@felixarntz felixarntz merged commit f9acb52 into WordPress:trunk Sep 30, 2025
5 checks passed
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