[Gax] RetryMiddleware keeps a reference to itself since v1.43.1, leaking the handler chain and its sockets in long-running processes

未关闭 适合新手
#9,813 1 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

维护者通常 1 天内回复

@Hectorhammett 已经在做这个了。

开始于 2026年10月23日。

评估

难度
2/5
预计耗时
1-3 小时
新手友好度
78/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
php
领域
backend

调研方向

报告指出,问题源自 RetryMiddleware.php 和 delayHandler 赋值。阅读该中间件,并追踪 handler 如何被调用以及如何传递给嵌套的重试中间件。在禁用循环收集的情况下复现 WeakReference 检查;取消对中间件的引用后,无需调用 gc_collect_cycles() 即可释放它,同时自定义延迟 handler 仍能正常工作,即为完成。

由索引模型根据 Issue 内容生成。

描述

Environment details
  • OS: Linux (Docker), reproduced on macOS too
  • PHP version: 8.3.36 (NTS)
  • Package name and version: google/gax v1.51.1 (regression since v1.43.1, v1.43.0 is fine)
  • Also: googleads/google-ads-php 35.2.0, guzzlehttp/guzzle 7.16.0, REST transport
Summary

Since v1.43.1 (#676, commit 5d3d6da, "RetryMiddleware should sleep on retry"), RetryMiddleware::__construct() stores a callable that points back to the middleware itself:

$this->delayHandler = ($delayHandler ?? [$this, 'sleepMillis']);

This creates a self-reference cycle, so a RetryMiddleware instance is never freed by reference counting. It is only freed when PHP's cycle collector runs. The middleware holds $nextHandler, which in turn holds the transport, the HTTP handler and the Guzzle client. So every call leaves its whole handler chain in memory, together with any open sockets.

Short-lived requests barely notice this. Long-running PHP processes do: queue consumers such as Symfony Messenger workers, daemons, CLI loops. In our worker every Google Ads call left 5 file descriptors open. After a few hours of normal load the process hit ulimit -n (1024) and started failing with Too many open files. The errors then surfaced as unrelated failures:

  • Class "GuzzleHttp\Promise\RejectedPromise" not found (the autoloader could not open the file)
  • Google\ApiCore\RetrySettings::load(): Argument #2 ($clientConfig) must be of type array, null given (file_get_contents() of the client config returned false)
Steps to reproduce
<?php
require 'vendor/autoload.php';

use Google\ApiCore\Middleware\RetryMiddleware;
use Google\ApiCore\RetrySettings;

gc_disable(); // make cycle collection explicit

$middleware = new RetryMiddleware(fn () => null, RetrySettings::constructDefault());
$ref = WeakReference::create($middleware);
unset($middleware);

var_dump($ref->get() === null); // v1.43.0: true; v1.43.1 … v1.51.1: false

gc_collect_cycles();
var_dump($ref->get() === null); // true: only the cycle collector frees it
Impact measured with a real client

We made 20 sequential GoogleAdsServiceClient::search() calls over REST against a local HTTPS stub. A new GAPIC client was built for each call, and no httpHandler was passed, so gax builds its default Guzzle client:

google/gax open fds after 20 calls after gc_collect_cycles()
v1.42.5, v1.43.0 +0 +0
v1.43.1, v1.43.2, v1.44.0, v1.45.0, v1.46.0, v1.48.0, v1.51.1 +100 +0

The only change to src/ between v1.43.0 and v1.43.1 is in Middleware/RetryMiddleware.php (the delayHandler addition).

Expected behavior

A RetryMiddleware instance, and the handler chain it references, is freed as soon as the call completes, as it was in v1.43.0.

Suggested fix

Do not store a callable bound to $this. Resolve the default when the delay is applied:

public function __construct(
    callable $nextHandler,
    RetrySettings $retrySettings,
    $deadlineMs = null,
    $retryAttempts = 0,
    ?callable $delayHandler = null
) {
    // ...
    $this->delayHandler = $delayHandler;
}

// in __invoke / retry():
if ($this->delayHandler) {
    ($this->delayHandler)($delayMs);
} else {
    $this->sleepMillis($delayMs);
}

Passing $this->delayHandler (possibly null) to the nested RetryMiddleware keeps the current behavior.

Workarounds
  • Pass one shared httpHandler to all clients, so the leaked chains do not each hold their own sockets.
  • Call gc_collect_cycles() periodically in long-running processes.
主要语言
PHP
星标
1.2k
派生
466
平均合并
1 天 17 小时
30 天内合并 PR
40

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

googleapis/google-cloud-php 的其他 Issue

查看 googleapis/google-cloud-php 的全部 Issue

相似的 Issue

更多 PHP Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。