Opened 4 weeks ago
Closed 3 weeks ago
#65094 closed defect (bug) (fixed)
AI: Prompt construction fails with fatal error when wp_ai_client_default_request_timeout filter returns negative number
| Reported by: |
|
Owned by: |
|
|---|---|---|---|
| Milestone: | 7.0 | Priority: | normal |
| Severity: | normal | Version: | |
| Component: | AI | Keywords: | has-patch has-unit-tests commit dev-reviewed |
| Focuses: | Cc: |
Description
Consider this code:
add_filter(
'wp_ai_client_default_request_timeout',
static function () {
return -1;
}
);
This causes a fatal error to occur when constructing a prompt with wp_ai_client_prompt():
WordPress\AiClient\Common\Exception\InvalidArgumentException: Request option "timeout" must be greater than or equal to 0. /var/www/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php:201 /var/www/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php:52 /var/www/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php:169 /var/www/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php:203 /var/www/src/wp-includes/ai-client.php:59
As exceptions are caught in ability callbacks (#65058) and generally WP_Error objects are returned, it would seem this exception should be caught in favor of issuing _doing_it_wrong().
Note: I stumbled upon this issue when looking at the AI codebase in PHPStan with rule level 10 applied. For this line:
<?php $default_timeout = (int) apply_filters( 'wp_ai_client_default_request_timeout', 30 );
PHPStan complains with:
Cannot cast mixed to int.
I started adding an is_numeric() check here and this led me down to looking at the validation logic in \WordPress\AiClient\Providers\Http\DTO\RequestOptions::validateTimeout().
Change History (32)
This ticket was mentioned in PR #11596 on WordPress/wordpress-develop by @westonruter.
4 weeks ago
#1
- Keywords has-patch has-unit-tests added
#2
@
4 weeks ago
Patch Testing & Review
I tested the latest patch locally and reviewed the changes in detail.
Environment
- WordPress: 7.0 RC2
- PHP: 8.3.x
- Server: Nginx
- Browser: Chrome
- OS: Windows
Summary
The patch successfully prevents a fatal error when invalid values are returned via the
wp_ai_client_default_request_timeout filter.
Previously, returning a negative value or invalid type would propagate into
RequestOptions::validateTimeout() and throw an uncaught exception, resulting in a fatal error.
With this patch, the value is validated earlier and handled gracefully.
Testing Performed
I tested multiple scenarios:
- Returning
-1→ Falls back to default30.0→_doing_it_wrong()triggered → No fatal error ✅
- Returning
45.5→ Value applied correctly ✅
- Returning
null→ Timeout disabled (null) as expected ✅
- Returning invalid type (e.g., array)
→ Falls back to default
→
_doing_it_wrong()triggered ✅
All cases behaved as expected and no regressions were observed.
Code Review Notes
- Validation logic in
WP_AI_Client_Prompt_Builder::__construct()is solid:- Accepts non-negative numeric values
- Explicitly supports
null - Safely falls back for invalid input
- Use of
_doing_it_wrong()aligns well with WordPress core practices and avoids hard failures inside filters
- Switching to
floatimproves flexibility and resolves PHPStan level 10 issues
- Additional improvements:
- Proper return type declarations
- Missing imports added
- Native property typing applied
Unit Tests
New PHPUnit tests provide good coverage:
- Valid float override
nullhandling- Negative value rejection
- Invalid type rejection
All tests pass locally.
Feedback
This is a well-rounded fix that:
- Improves resilience of the AI client
- Prevents fatal errors in edge cases
- Enhances developer experience with clear feedback
The approach is consistent with core standards.
Conclusion
Patch tested successfully — works as expected.
No issues found during testing in this environment; needs to be tested across different environments as well for better clarity.
#3
@
4 weeks ago
Tested this issue in a local WordPress development environment.
Steps:
- Set up local WordPress Core
- Applied the wp_ai_client_default_request_timeout filter
- Initially placed it in wp-config.php, which caused a fatal error (add_filter not available at that stage)
- Moved the filter to functions.php, where WordPress is fully loaded
Result:
- No fatal errors when applied correctly
- WordPress runs normally
Conclusion:
The behavior works as expected when the filter is used in the proper context.
#4
@
4 weeks ago
Patch Testing Report – Ticket #65094
I tested the patch locally in a WordPress 7.0 environment.
Summary:
The patch successfully prevents a fatal error when invalid values are returned via the wp_ai_client_default_request_timeout filter. Previously, invalid values (e.g., -1) caused an uncaught exception in RequestOptions::validateTimeout().
Testing Performed:
Returning -1 → Falls back to default (30) → No fatal error ✅
Returning 45.5 → Value applied correctly ✅
Returning null → Timeout disabled as expected ✅
Returning invalid type (array) → Falls back to default → No fatal error ✅
Additional Observation:
A PHP warning was encountered during testing due to an undefined variable ($default_timeout) after applying the patch manually. This appears to be related to the local modification rather than the intended patch behavior.
Conclusion:
The patch works as expected. It properly validates the timeout value, prevents fatal errors, and handles invalid input gracefully.
#5
@
4 weeks ago
Tested the patch locally in a WordPress 7.0 development environment.
Testing setup:
- Theme: Twenty Twenty-Five (v1.4)
- Added the following filter and test trigger in
functions.php:
`
add_filter(
'wp_ai_client_default_request_timeout',
function () {
return -1;
}
);
add_action('init', function () {
if ( function_exists('wp_ai_client_prompt') ) {
wp_ai_client_prompt("Test prompt");
}
});
`
Before applying the patch:
- Encountered a fatal error:
InvalidArgumentException: Request option "timeout" must be greater than or equal to 0
screenshot : https://prnt.sc/yC5V3AwWnwyF
After applying the patch:
- No fatal error occurs ✅
- Invalid timeout value is handled gracefully
- Application continues to run normally
The patch successfully prevents the fatal error and aligns with expected WordPress error-handling practices.
Patch works as expected 👍
@westonruter commented on PR #11596:
4 weeks ago
#6
@justlevine:
what benefit does changing
wp_ai_client_default_request_timeoutto support?floatinstead of _just_float?
I suppose because this matches the types supported by RequestOptions. Also, doing strict equality checks between float values is not safe. For example: https://3v4l.org/QYUem
So I don't think it is completely reliable to use 0.0 as the value for disabling the timeout. Better to use null to match RequestOptions.
@justlevine commented on PR #11596:
4 weeks ago
#7
@justlevine:
what benefit does changing
wp_ai_client_default_request_timeoutto support?floatinstead of _just_float?
I suppose because this matches the types supported by
RequestOptions. Also, doing strict equality checks between float values is not safe. For example: https://3v4l.org/QYUem
So I don't think it is completely reliable to use
0.0as the value for disabling the timeout. Better to usenullto matchRequestOptions.
@westonruter Not sure I follow your reliability concerns: https://3v4l.org/vk3Pc . But worst case we can coerce to whatever "stable" check you feel comfortable with: $default_timeout = (int) $default_timeout === 0 ? ... or ! empty( $timeout ) ? ... or whatever all seem a lot more easy to reason with then multiple conditional paths.
As far as matching RequestOptions, personally I don't see a need or justification for an internal hook to match the shape of a DTO, if our internal implementation needs the whole shape.
However, if folks feel a need to align the two, then I'd suggest making the type on the DTO stricter, not loosening our _public_ API (the hook signature) to accommodate the unnecessary polymorphism and edge cases.
@westonruter commented on PR #11596:
4 weeks ago
#8
Not sure I follow your reliability concerns: https://3v4l.org/vk3Pc . But worst case we can coerce to whatever "stable" check you feel comfortable with:
$default_timeout = (int) $default_timeout === 0 ? ...or! empty( $timeout ) ? ...or whatever all feel easier to reason with than multiple conditional paths.
It's not reliable to check if a float value is equal to 0.0, especially if arithmetic has been done on the value. Since the value has come from a filter, we do not know what operations have been done on it.
Casting the value to int is not viable either because it will prevent values like 0.5 from being usable.
@justlevine commented on PR #11596:
4 weeks ago
#9
Not sure I follow your reliability concerns: https://3v4l.org/vk3Pc . But worst case we can coerce to whatever "stable" check you feel comfortable with:
$default_timeout = (int) $default_timeout === 0 ? ...or! empty( $timeout ) ? ...or whatever all feel easier to reason with than multiple conditional paths.
It's not reliable to check if a float value is equal to
0.0, especially if arithmetic has been done on the value. Since the value has come from a filter, we do not know what operations have been done on it.
Casting the value to
intis not viable either because it will prevent values like0.5from being usable.
what arethmeric though? we're casting it on our side and in full control of the value. And yeah casting locally to int _just for a falsely check_ wouldn't prevent 0.5 at all...
@westonruter commented on PR #11596:
4 weeks ago
#10
what arithmetic though? we're casting it on our side _after_ the filter, and we in full control of the value and what happens to it afterwards. And yeah casting locally to int _just for a falsely check_ wouldn't prevent 0.5 at all... #11596 (comment)
For example:
add_filter( 'wp_ai_client_default_request_timeout', function ( $value ) {
return $value - (float) get_option( 'my_timeout_delta' );
} );
The value being passed in is a float to begin with here. So the casting being added here is just in case there is a string value in one of the filter callbacks.
@westonruter commented on PR #11596:
4 weeks ago
#11
Something else to consider is the fact that float values can be infinity (INF). That would effectively be the same as null. In that case, should \WordPress\AI_Client\HTTP\WordPress_HTTP_Client::prepare_wp_args() be updated to account for that?
-
src/wp-includes/ai-client/adapters/class-wp-ai-client-http-client.php
a b class WP_AI_Client_HTTP_Client implements ClientInterface, ClientWithOptionsInte 138 138 ); 139 139 140 140 if ( null !== $options ) { 141 if ( null !== $options->getTimeout() ) {141 if ( null !== $options->getTimeout() && is_finite( $options->getTimeout() ) ) { 142 142 $args['timeout'] = $options->getTimeout(); 143 143 }
While the timeout arg for WP HTTP says it takes a float, it's not clear that INF would be allowed.
Oh, something more important I'm seeing as well: currently when null is passed, this is preventing prepare_wp_args() from setting the timeout, which is causing the _default_ timeout of 5 to be used when the request is actually set. This doesn't seem entirely expected. If null (or INF) is supplied, then it would seem that _something_ should be set to the default value from being used.
@justlevine commented on PR #11596:
4 weeks ago
#12
what arithmetic though? we're casting it on our side _after_ the filter, and we in full control of the value and what happens to it afterwards. And yeah casting locally to int _just for a falsely check_ wouldn't prevent 0.5 at all... #11596 (comment)
For example:
I feel like that example bolsters _my_ point? Adding a line:
+ add_filter( 'wp_ai_client_default_request_timeout', '__return_null' ); // somewhere else
add_filter( 'wp_ai_client_default_request_timeout', function ( $value ) {
return $value - (float) get_option( 'my_timeout_delta' );
} );
since
$valueis assumed afloat, if someone forgets to cast it fromnulllike in your example it won't be an issue. Meanwhile thereturn (float) apply_filters()means that even if they return an int or null or empty string, we can pass to to our dto... e.g.add_filter('wp_ai_client_default_request_timeout', '__return_false' );where itl be caught by PHPStan but only error if a filter like yours conflicts.
Perhaps you can add a test that would red if someone refactors this in the future to cast the results of apply_filters(), but pass because we're doing it with null|float and explicit checking? Would prevent the regression you're clearly concerned about, and be a easier for me to follow concretely.
If null (or INF) is supplied, then it would seem that something should be set to the default value from being used.
(Putting aside that the null issue is solved by not allowing it/casting to float), remember this whole thing is just a shallow wrapper for WordPress/requests. If that library is missing handling for one of these edge cases, then IMO the behavior should be handled _there_, not absorbed downstream in our (or any other component). even without the type safety these aren't particularly common foot guns I don't think.
@westonruter commented on PR #11596:
4 weeks ago
#13
since
$valueis assumed afloat, if someone forgets to cast it fromnulllike in your example it won't be an issue. Meanwhile thereturn (float) apply_filters()means that even if they return an int or null or empty string, we can pass to to our dto... e.g.add_filter('wp_ai_client_default_request_timeout', '__return_false' );where itl be caught by PHPStan but only error if a filter like yours conflicts.
I'm just trying to say that the return value if an applied filter will be a float and we can't reliably compare it with 0.0 because of floating point math.
Because RequestOptions::validateTimeout() allows a float or null, where null apparently is supposed to mean that there is no timeout enforced (but maybe I'm wrong). It seems there should be an ability to disable a timeout, such as in a WP-CLI context. Returning null would make sense as the way to disable the timeout given what is allowed by RequestOptions. Otherwise, if we don't want to allow null, we could document INF as a valid value. But we can't use 0.0 because testing equality with floats is not reliable.
@justlevine commented on PR #11596:
4 weeks ago
#14
Because RequestOptions::validateTimeout() allows a float or null, where null apparently is supposed to mean that there is no timeout enforced (but maybe I'm wrong).
If I'm not mistaken and as you also noted, wp_safe_remote_request() / WordPress/Requests treats 0 (or 0.0 ) as no timeout, whereas null falls back to the default fallback of _5s_ and not our 30s that we use for the hook here. The dto as _implemented_ follows that, so IMO it make sense to lock down the dto not to change the meaning of null to use it in our hook.
I still don't understand the point youre trying to make about float-math. There's never a time wed need to do a float === float check, literally any falsey value can be coerced to the 0 or 0.0 that we to send the DTO, and unless you're saying you want it to be positive-float|0 (which imo is still an improvement over *|null), I'm lost as to what edge case this protects against. So with that I'm going to respectfully tap out (unless a test case gets added that covers it) 🙇♂️
@justlevine commented on PR #11596:
4 weeks ago
#15
( fwiw http_request_timeout is also typed as a float https://github.com/WordPress/wordpress-develop/blob/074792ef21d9160a8fabdda215f8bf48f7a91d43/src/wp-includes/class-wp-http.php#L178-L181, but consistency for its own sake isn't an argument, if it's deserving we can just as easily broaden the type there too. )
@westonruter commented on PR #11596:
4 weeks ago
#16
There's never a time wed need to do a float === float check
This is what you suggested in https://github.com/WordPress/wordpress-develop/pull/11596#discussion_r3111408318.
@justlevine commented on PR #11596:
4 weeks ago
#17
There's never a time wed need to do a float === float check
This is what you suggested in #11596 (comment).
yes and as the comment there and the rest of our conversation indicate, that was me attempting to understanding your concern, but IMO even this is unnecessary and we can just let folks pass any float value they want and not do any coercing beyond the negative-int check in the dto. 🤷♂️
@westonruter commented on PR #11596:
4 weeks ago
#18
If I'm not mistaken and as you also noted,
wp_safe_remote_request()/WordPress/Requeststreats0(or0.0) as no timeout
I don't think I suggested this. A timeout of zero actually gets increased to 1 second:
I tested this by creating a file called sleep-2.php locally:
<?php sleep( 2 ); echo "Awake!";
Then, from that directory I started a server:
php -S 0.0.0.0:8888
Then in the wordpress-develop environment, I put this file:
<?php $response = wp_remote_get( 'http://host.docker.internal:8888/sleep-2.php ', array( 'timeout' => 0.0 ) ); if ( is_wp_error( $response ) ) { WP_CLI::error( $response->get_error_message() ); } WP_CLI::log( 'Code: ' . wp_remote_retrieve_response_code( $response ) ); WP_CLI::log( 'Body: ' . wp_remote_retrieve_body( $response ) );
And when I do this:
npm run env:cli eval-file -- try-timeout.php
I get this:
Error: cURL error 28: Operation timed out after 1004 milliseconds with 0 bytes received
So a timeout of 0.0 does _not_ mean the timeout is disabled in WordPress HTTP API.
If I change the timeout to 3.0, then I get this:
Code: 200
Body: Awake!
Note that if I use INF here:
$response = wp_remote_get(
'http://host.docker.internal:8888/sleep-2.php ',
array( 'timeout' => INF )
);
Then this also does not time-out, so it seems to be valid at least for the cURL transport.
So we cannot use a value of 0.0 to indicate the timeout is disabled. Either we have the filter return null, INF, or just have the user return a super big number. To override the WP HTTP API's 5 second default timeout, we'll need to provide _something_, regardless of the return value from wp_ai_client_default_request_timeout. Either INF or a super big number seem to work, so if null is returned we'll need to map to either of those values.
#19
@
4 weeks ago
Tested the issue and confirm that return a negative value for the wp_ai_client_default_request_timeout filter generate a fatal error during prompt construction.
The patch correctly handle invalid timeout values and prevents the crash. AI requests now work as expected.
#20
@
4 weeks ago
Additionally tested with:
- null return → works as expected
- float values → handled correctly
No regressions observed.
#21
@
4 weeks ago
Patch Testing Report
Patch Tested: https://core.trac.wordpress.org/ticket/65094
Environment:
- WordPress: 7.0 RC2
- PHP: 8.2.x
- Server: Apache
- Browser: Chrome
- OS: Ubuntu
Test Summary:
- Set up the WordPress Core 7.0 RC2 locally
- Applied the wp_ai_client_default_request_timeout filter in the theme's functions.php and tested the possible cases.
Result:
The patch is working as expected. It has been validated successfully and does not produce any fatal errors when applied correctly.
This ticket was mentioned in Slack in #core by audrasjb. View the logs.
3 weeks ago
#23
@
3 weeks ago
As per today's bug scrub: The PR was successfully tested several times and approved by @justlevine. I think we're getting close to a resolution. @westonruter do you need more testing on this?
#26
@
3 weeks ago
- Keywords dev-feedback added
- Resolution fixed deleted
- Status changed from closed to reopened
Re-opening for merging into 7.0 branch after sign-off.
#27
@
3 weeks ago
Performed additional edge-case testing on 7.0 RC2:
- null → handled safely, no fatal error
- INF → no crash, request proceeds as expected
- float (0.5) → accepted and applied correctly
- invalid string → ignored, fallback applied
- large value → works without issues
No regressions observed across all cases.
The fix is robust and handles unexpected filter inputs gracefully.
Suggestion:
It may be helpful to document supported return types for this filter (float, null, INF) to improve developer clarity.
#28
@
3 weeks ago
Steps performed:
- Set up a fresh local WordPress core environment
- Attempted to apply the
wp_ai_client_default_request_timeoutfilter - Initially added the filter in
wp-config.php, which resulted in a fatal error sinceadd_filter()is not available at that early stage of execution - Relocated the filter to
functions.php, where WordPress is fully loaded and hooks are accessible
Result:
- No fatal errors occurred when the filter was implemented in the correct location
- WordPress functioned as expected without any issues
Conclusion:
The behavior is correct and expected. The filter works properly when used within the appropriate context (i.e., after WordPress has fully initialized), and the earlier error was due to placing it too early in the load sequence.
#29
@
3 weeks ago
I tested the issue and confirmed that returning a negative value for the wp_ai_client_default_request_timeout filter causes a fatal error during prompt construction.
The patch properly handles invalid timeout values and prevents the crash. AI requests are now functioning as expected.
#30
@
3 weeks ago
Here's a quick way to test this.
Add a this as a try-wp-ai-client-negative-default-request-timeout.php file to the root of a WP install:
<?php add_filter( 'wp_ai_client_default_request_timeout', static function () { return -1; } ); echo wp_ai_client_prompt( 'When is Christmas?' )->generate_text();
Then invoke with WP-CLI:
npm run env:cli -- eval-file try-wp-ai-client-negative-default-request-timeout.php
Before:
Fatal error: Uncaught WordPress\AiClient\Common\Exception\InvalidArgumentException: Request option "timeout" must be greater than or equal to 0. in /var/www/src/wp-includes/php-ai-client/src/Providers/Http/DTO/RequestOptions.php:201
After:
Notice: Function
WP_AI_Client_Prompt_Builder::__constructwas called incorrectly. Thewp_ai_client_default_request_timeoutfilter must return a non-negative number. Please see Debugging in WordPress for more information. (This message was added in version 7.0.0.) in /var/www/src/wp-includes/functions.php on line 6173
Christmas is celebrated on December 25th each year.
Trac ticket: https://core.trac.wordpress.org/ticket/65094
The primary change here is in
WP_AI_Client_Prompt_Builder::__construct()to check that the return value of thewp_ai_client_default_request_timeoutfilter is in fact a non-negativefloat. It also newly allows the valuenull. Otherwise, the filtered value is discarded in favor of using the original default of 30, and a_doing_it_wrong()is issued. Without this check, a fatal error would ensue due to an exception being thrown by\WordPress\AiClient\Providers\Http\DTO\RequestOptions::validateTimeout().In addition to this change, static analysis issues were resolved:
floatinstead ofintfor thewp_ai_client_default_request_timeout.MessageandMessagePartfor the PHPDoc forwp_ai_client_prompt().wp_ai_client_prompt()and\WP_AI_Client_Cache::getMultiple().WP_AI_Client_HTTP_Client.The following PHPStan level 10 issues are resolved:
src/wp-includes/ai-client.php: Parameter $prompt of function wp_ai_client_prompt() has invalid type Message.src/wp-includes/ai-client.php: Parameter $prompt of function wp_ai_client_prompt() has invalid type MessagePart.src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php: Cannot cast mixed to int.## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Optus 4.7
Used for: Research