guzzle/guzzle · error · GuzzleHttp\Exception\InvalidArgumentException
on_stats must be callable
Error message
on_stats must be callable
What it means
Thrown by StreamHandler::__invoke when the 'on_stats' request option is set but is not callable. The handler invokes on_stats as a function with a TransferStats argument after each transfer, so a non-callable value would cause a fatal TypeError at invocation time; this guard fails fast at dispatch instead.
Source
Thrown at src/Handler/StreamHandler.php:192
throw new RequestException('The stream handler cannot guarantee a multiplexed protocol; required multiplexing needs a cURL handler.', $request);
}
$protocolVersion = $request->getProtocolVersion();
if ('' === $protocolVersion) {
throw new RequestException('HTTP protocol version must not be empty.', $request);
}
if (1 !== \preg_match('/^\d+(?:\.\d+)?$/D', $protocolVersion)) {
throw new RequestException('HTTP protocol version must be a valid HTTP version number.', $request);
}
if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) {
throw new RequestException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request);
}
if (isset($options['on_stats']) && !\is_callable($options['on_stats'])) {
throw new InvalidArgumentException('on_stats must be callable');
}
$startTime = isset($options['on_stats']) ? Clock::now() : null;
self::rejectUnsupportedRequestOptions($request, $options);
$this->rejectStreamingWithConnectionCaps($options);
$this->assertTransportSharingSupported();
// The stream wrapper sends HEAD request bodies, unlike cURL's NOBODY
// path, so validate their framing normally.
$framing = RequestFraming::analyze($request->withoutHeader('Expect'));
$request = $framing->request;
try {
self::assertRequestUriSupported($request, $options);
$body = $framing->materialize();
if ($framing->contentLength === null && ($body !== '' || \in_array($request->getMethod(), ['PUT', 'POST'], true))) {
$request = $request->withHeader('Content-Length', (string) \strlen($body));View on GitHub (pinned to 9b200fc580)
Solutions
- Pass a callable: a closure, an invokable object, or a valid [Class, 'method'] array.
- For string callbacks, ensure the function exists and is in scope (use the fully-qualified name for namespaced functions).
- Verify with is_callable($value) before assigning the option.
Example fix
// before
$client->request('GET', $url, ['on_stats' => 'logStats']); // logStats not defined
// after
$client->request('GET', $url, ['on_stats' => function (TransferStats $s) { /* ... */ }]); Defensive patterns
Strategy: type-guard
Validate before calling
if (isset($options['on_stats']) && !is_callable($options['on_stats'])) {
throw new \InvalidArgumentException('on_stats must be callable');
} Type guard
function isOnStatsCallable(mixed $v): bool { return $v === null || is_callable($v); } Prevention
- Always pass a closure or a valid [Class, 'method'] array for on_stats.
- For string callbacks use the fully-qualified function name in namespaced code.
- Verify with is_callable() before assigning the option.
When it happens
Trigger: Passing ['on_stats' => 'myFunc'] when the function is not defined/loaded; passing an array that is not a valid callable; passing an object lacking __invoke; passing a closure stored in a variable that is null.
Common situations: Refactoring that removes a stats callback but leaves the option key; namespaced code where the callback string lacks its namespace; conditional code paths that sometimes set on_stats to a non-callable.
Related errors
- progress client option must be callable
- on_stats must be callable
- on_trailers must be callable
- Invalid StreamHandler constructor option "%s".
- %s must be a positive integer.
AI-assisted analysis of guzzle/guzzle@9b200fc580 (2026-08-04).
Data as JSON: /data/errors/cf8b5f553c544b00.json.
Report an issue: GitHub.