Skip to content

Fix plugin download timeouts by using low-speed timeout instead of hard timeout - #1913

Merged
bcotrim merged 1 commit into
trunkfrom
stu-858-downloading-some-plugins-fails-operation-timed-out
Oct 22, 2025
Merged

Fix plugin download timeouts by using low-speed timeout instead of hard timeout#1913
bcotrim merged 1 commit into
trunkfrom
stu-858-downloading-some-plugins-fails-operation-timed-out

Conversation

@bcotrim

@bcotrim bcotrim commented Oct 22, 2025

Copy link
Copy Markdown
Contributor

Related issues

Proposed Changes

  • Replaced hard CURLOPT_TIMEOUT (30s) with low-speed timeout to allow large plugin downloads
  • Set CURLOPT_LOW_SPEED_LIMIT to 1KB/s and CURLOPT_LOW_SPEED_TIME to 30s in the HTTP request timeout mu-plugin
  • Kept overall timeout at 300s timeout
  • Updated both src/lib/wordpress-provider/playground-cli/mu-plugins.ts and vendor/wp-now/src/wp-now.ts
  • This allows large downloads (like Jetpack at 33MB) to complete while still failing fast on stalled connections

Testing Instructions

  • Install Network Link Conditioner (from Apple's Additional Tools for Xcode)
  • Set throttling to a slow profile (e.g., "3G" or "Edge")
  • In Studio, navigate to /wp-admin/plugin-install.php
  • Try installing Jetpack plugin (33MB)
  • Expected: Should complete successfully even on throttled connection (may take several minutes)
  • With the old 30s timeout, this would fail with "Operation timed out after 30014 milliseconds"
  • Optionally verify with normal internet by turning off Network Link Conditioner and installing Jetpack - should complete quickly without any timeout issues

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
@bcotrim bcotrim self-assigned this Oct 22, 2025
@bcotrim
bcotrim requested a review from a team October 22, 2025 12:02
@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Test Results

Comparing 301c1f8 vs trunk

site-editor

Metric trunk 301c1f8 Diff Change
load 11376.00 ms 8926.00 ms -2450.00 ms 🟢 -21.5%

site-startup

Metric trunk 301c1f8 Diff Change
siteCreation 12074.00 ms 11050.00 ms -1024.00 ms 🟢 -8.5%
siteStartup 3948.00 ms 3929.00 ms -19.00 ms 🟢 -0.5%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change

@ivan-ottinger ivan-ottinger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice! The changes look good and I can confirm they fix the issue when installing Jetpack on DSL speeds. If I tested with slower connection (e.g. 3G or EDGE), it failed as with the current trunk.

Install Network Link Conditioner (from Apple's Additional Tools for Xcode)

This took me a bit longer to figure our where to find the tool so I am linking it here - if anyone else would like to use it:
https://developer.apple.com/download/all/?q=Additional%20Tools

It is bundled inside Additional Tools and I found the tool quite buggy. For instance, my Preferences crashed multiple times and it was necessary to force-close it.

@epeicher epeicher left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @bcotrim for improving this! I have tested it with DSL connection and while it was failing in Production, it has worked using this branch. Changes also LGTM! :shipit:

Speed Production version This branch
Image Image Image
@bcotrim
bcotrim merged commit c70246d into trunk Oct 22, 2025
13 checks passed
@bcotrim
bcotrim deleted the stu-858-downloading-some-plugins-fails-operation-timed-out branch October 22, 2025 15:18
chubes4 added a commit to chubes4/studio that referenced this pull request Apr 16, 2026
…imeout)

Fixes Automattic#3118.

The mu-plugin injected into every Studio site by getStandardMuPlugins
sets CURLOPT_LOW_SPEED_LIMIT=1024 + CURLOPT_LOW_SPEED_TIME=30, aborting
any outbound HTTP request where transfer rate stays below 1 KB/s for
30 consecutive seconds.

These values were correct for the original motivation (plugin/theme
downloads, Automattic#1407/Automattic#1913) -- a dead mirror delivering zero bytes is a
broken connection, and aborting fast is the right behavior.

They are incorrect for AI API requests, now a common Studio use case
for plugin developers building on top of OpenAI, Anthropic, Google,
etc. Non-streaming wp_remote_post calls to LLM providers produce zero
bytes on the socket until the model emits its first token. On flagship
models with long prompts, that first token commonly takes 30 to 90
seconds to arrive. The watchdog fires before content is delivered and
aborts with cURL error 28 ("Operation too slow").

Raise CURLOPT_LOW_SPEED_TIME from 30 seconds to 120 seconds. All other
thresholds unchanged, including the 300-second http_request_timeout
outer cap. A working plugin download produces far more than 1 KB/s
throughout its lifetime, so the low-speed watchdog never triggers for
that use case -- the Automattic#1913 Jetpack-on-3G test passes identically.
Dead-connection detection still works; it takes 120 seconds to detect
instead of 30, which is a small DX regression for an uncommon failure
mode, and the 300-second outer cap still catches fully hung requests.

The only observable behavior change: AI API requests that would have
died at 30 seconds now complete normally (up to the 300-second outer
cap, which is already enough for all current flagship models).

Reproduced cURL 28 aborts from Data Machine's daily memory compaction
against api.openai.com with gpt-5.4 on a ~30 KB prompt. Built the CLI
locally, verified the compiled mu-plugin contains the new value via
PHP reflection on the runtime closure
(CURLOPT_LOW_SPEED_TIME = 120), re-ran the compaction task against
the same flagship model on the same site, and confirmed end-to-end
completion.

AI assistance: I used Claude Code to research the history of this
mu-plugin (tracing through Automattic#1407 and Automattic#1913), evaluate the side
effects of each possible adjustment (raising the time vs. lowering
the limit vs. an escape-hatch filter), and draft this commit message
and the accompanying PR body. The engineering decision to raise the
time threshold to 120 seconds (rather than add a filter or
discriminate by URL) was mine after reviewing the options. I
verified the fix end-to-end against a live Studio site and the
original reproduction.
chubes4 added a commit to chubes4/studio that referenced this pull request Apr 17, 2026
…imeout)

Fixes Automattic#3118.

The mu-plugin injected into every Studio site by getStandardMuPlugins
sets CURLOPT_LOW_SPEED_LIMIT=1024 + CURLOPT_LOW_SPEED_TIME=30, aborting
any outbound HTTP request where transfer rate stays below 1 KB/s for
30 consecutive seconds.

These values were correct for the original motivation (plugin/theme
downloads, Automattic#1407/Automattic#1913) -- a dead mirror delivering zero bytes is a
broken connection, and aborting fast is the right behavior.

They are incorrect for AI API requests, now a common Studio use case
for plugin developers building on top of OpenAI, Anthropic, Google,
etc. Non-streaming wp_remote_post calls to LLM providers produce zero
bytes on the socket until the model emits its first token. On flagship
models with long prompts, that first token commonly takes 30 to 90
seconds to arrive. The watchdog fires before content is delivered and
aborts with cURL error 28 ("Operation too slow").

Raise CURLOPT_LOW_SPEED_TIME from 30 seconds to 120 seconds. All other
thresholds unchanged, including the 300-second http_request_timeout
outer cap. A working plugin download produces far more than 1 KB/s
throughout its lifetime, so the low-speed watchdog never triggers for
that use case -- the Automattic#1913 Jetpack-on-3G test passes identically.
Dead-connection detection still works; it takes 120 seconds to detect
instead of 30, which is a small DX regression for an uncommon failure
mode, and the 300-second outer cap still catches fully hung requests.

The only observable behavior change: AI API requests that would have
died at 30 seconds now complete normally (up to the 300-second outer
cap, which is already enough for all current flagship models).

Reproduced cURL 28 aborts from Data Machine's daily memory compaction
against api.openai.com with gpt-5.4 on a ~30 KB prompt. Built the CLI
locally, verified the compiled mu-plugin contains the new value via
PHP reflection on the runtime closure
(CURLOPT_LOW_SPEED_TIME = 120), re-ran the compaction task against
the same flagship model on the same site, and confirmed end-to-end
completion.

AI assistance: I used Claude Code to research the history of this
mu-plugin (tracing through Automattic#1407 and Automattic#1913), evaluate the side
effects of each possible adjustment (raising the time vs. lowering
the limit vs. an escape-hatch filter), and draft this commit message
and the accompanying PR body. The engineering decision to raise the
time threshold to 120 seconds (rather than add a filter or
discriminate by URL) was mine after reviewing the options. I
verified the fix end-to-end against a live Studio site and the
original reproduction.
chubes4 added a commit to chubes4/studio that referenced this pull request Apr 20, 2026
…imeout)

Fixes Automattic#3118.

The mu-plugin injected into every Studio site by getStandardMuPlugins
sets CURLOPT_LOW_SPEED_LIMIT=1024 + CURLOPT_LOW_SPEED_TIME=30, aborting
any outbound HTTP request where transfer rate stays below 1 KB/s for
30 consecutive seconds.

These values were correct for the original motivation (plugin/theme
downloads, Automattic#1407/Automattic#1913) -- a dead mirror delivering zero bytes is a
broken connection, and aborting fast is the right behavior.

They are incorrect for AI API requests, now a common Studio use case
for plugin developers building on top of OpenAI, Anthropic, Google,
etc. Non-streaming wp_remote_post calls to LLM providers produce zero
bytes on the socket until the model emits its first token. On flagship
models with long prompts, that first token commonly takes 30 to 90
seconds to arrive. The watchdog fires before content is delivered and
aborts with cURL error 28 ("Operation too slow").

Raise CURLOPT_LOW_SPEED_TIME from 30 seconds to 120 seconds. All other
thresholds unchanged, including the 300-second http_request_timeout
outer cap. A working plugin download produces far more than 1 KB/s
throughout its lifetime, so the low-speed watchdog never triggers for
that use case -- the Automattic#1913 Jetpack-on-3G test passes identically.
Dead-connection detection still works; it takes 120 seconds to detect
instead of 30, which is a small DX regression for an uncommon failure
mode, and the 300-second outer cap still catches fully hung requests.

The only observable behavior change: AI API requests that would have
died at 30 seconds now complete normally (up to the 300-second outer
cap, which is already enough for all current flagship models).

Reproduced cURL 28 aborts from Data Machine's daily memory compaction
against api.openai.com with gpt-5.4 on a ~30 KB prompt. Built the CLI
locally, verified the compiled mu-plugin contains the new value via
PHP reflection on the runtime closure
(CURLOPT_LOW_SPEED_TIME = 120), re-ran the compaction task against
the same flagship model on the same site, and confirmed end-to-end
completion.

AI assistance: I used Claude Code to research the history of this
mu-plugin (tracing through Automattic#1407 and Automattic#1913), evaluate the side
effects of each possible adjustment (raising the time vs. lowering
the limit vs. an escape-hatch filter), and draft this commit message
and the accompanying PR body. The engineering decision to raise the
time threshold to 120 seconds (rather than add a filter or
discriminate by URL) was mine after reviewing the options. I
verified the fix end-to-end against a live Studio site and the
original reproduction.
chubes4 added a commit to chubes4/studio that referenced this pull request Apr 23, 2026
…imeout)

Fixes Automattic#3118.

The mu-plugin injected into every Studio site by getStandardMuPlugins
sets CURLOPT_LOW_SPEED_LIMIT=1024 + CURLOPT_LOW_SPEED_TIME=30, aborting
any outbound HTTP request where transfer rate stays below 1 KB/s for
30 consecutive seconds.

These values were correct for the original motivation (plugin/theme
downloads, Automattic#1407/Automattic#1913) -- a dead mirror delivering zero bytes is a
broken connection, and aborting fast is the right behavior.

They are incorrect for AI API requests, now a common Studio use case
for plugin developers building on top of OpenAI, Anthropic, Google,
etc. Non-streaming wp_remote_post calls to LLM providers produce zero
bytes on the socket until the model emits its first token. On flagship
models with long prompts, that first token commonly takes 30 to 90
seconds to arrive. The watchdog fires before content is delivered and
aborts with cURL error 28 ("Operation too slow").

Raise CURLOPT_LOW_SPEED_TIME from 30 seconds to 120 seconds. All other
thresholds unchanged, including the 300-second http_request_timeout
outer cap. A working plugin download produces far more than 1 KB/s
throughout its lifetime, so the low-speed watchdog never triggers for
that use case -- the Automattic#1913 Jetpack-on-3G test passes identically.
Dead-connection detection still works; it takes 120 seconds to detect
instead of 30, which is a small DX regression for an uncommon failure
mode, and the 300-second outer cap still catches fully hung requests.

The only observable behavior change: AI API requests that would have
died at 30 seconds now complete normally (up to the 300-second outer
cap, which is already enough for all current flagship models).

Reproduced cURL 28 aborts from Data Machine's daily memory compaction
against api.openai.com with gpt-5.4 on a ~30 KB prompt. Built the CLI
locally, verified the compiled mu-plugin contains the new value via
PHP reflection on the runtime closure
(CURLOPT_LOW_SPEED_TIME = 120), re-ran the compaction task against
the same flagship model on the same site, and confirmed end-to-end
completion.

AI assistance: I used Claude Code to research the history of this
mu-plugin (tracing through Automattic#1407 and Automattic#1913), evaluate the side
effects of each possible adjustment (raising the time vs. lowering
the limit vs. an escape-hatch filter), and draft this commit message
and the accompanying PR body. The engineering decision to raise the
time threshold to 120 seconds (rather than add a filter or
discriminate by URL) was mine after reviewing the options. I
verified the fix end-to-end against a live Studio site and the
original reproduction.
chubes4 added a commit to chubes4/studio that referenced this pull request May 29, 2026
…imeout)

Fixes Automattic#3118.

The mu-plugin injected into every Studio site by getStandardMuPlugins
sets CURLOPT_LOW_SPEED_LIMIT=1024 + CURLOPT_LOW_SPEED_TIME=30, aborting
any outbound HTTP request where transfer rate stays below 1 KB/s for
30 consecutive seconds.

These values were correct for the original motivation (plugin/theme
downloads, Automattic#1407/Automattic#1913) -- a dead mirror delivering zero bytes is a
broken connection, and aborting fast is the right behavior.

They are incorrect for AI API requests, now a common Studio use case
for plugin developers building on top of OpenAI, Anthropic, Google,
etc. Non-streaming wp_remote_post calls to LLM providers produce zero
bytes on the socket until the model emits its first token. On flagship
models with long prompts, that first token commonly takes 30 to 90
seconds to arrive. The watchdog fires before content is delivered and
aborts with cURL error 28 ("Operation too slow").

Raise CURLOPT_LOW_SPEED_TIME from 30 seconds to 120 seconds. All other
thresholds unchanged, including the 300-second http_request_timeout
outer cap. A working plugin download produces far more than 1 KB/s
throughout its lifetime, so the low-speed watchdog never triggers for
that use case -- the Automattic#1913 Jetpack-on-3G test passes identically.
Dead-connection detection still works; it takes 120 seconds to detect
instead of 30, which is a small DX regression for an uncommon failure
mode, and the 300-second outer cap still catches fully hung requests.

The only observable behavior change: AI API requests that would have
died at 30 seconds now complete normally (up to the 300-second outer
cap, which is already enough for all current flagship models).

Reproduced cURL 28 aborts from Data Machine's daily memory compaction
against api.openai.com with gpt-5.4 on a ~30 KB prompt. Built the CLI
locally, verified the compiled mu-plugin contains the new value via
PHP reflection on the runtime closure
(CURLOPT_LOW_SPEED_TIME = 120), re-ran the compaction task against
the same flagship model on the same site, and confirmed end-to-end
completion.

AI assistance: I used Claude Code to research the history of this
mu-plugin (tracing through Automattic#1407 and Automattic#1913), evaluate the side
effects of each possible adjustment (raising the time vs. lowering
the limit vs. an escape-hatch filter), and draft this commit message
and the accompanying PR body. The engineering decision to raise the
time threshold to 120 seconds (rather than add a filter or
discriminate by URL) was mine after reviewing the options. I
verified the fix end-to-end against a live Studio site and the
original reproduction.
fredrikekelund added a commit that referenced this pull request Jul 7, 2026
…imeout) (#3120)

## Related issues

Fixes #3118.

## Proposed Changes

- Raise `CURLOPT_LOW_SPEED_TIME` from 30 seconds to 120 seconds in
`getStandardMuPlugins`' `0-http-request-timeout.php`.
- Update the inline documentation to explain the new threshold and the
AI-API-request use case.
- No other thresholds changed: `CURLOPT_CONNECTTIMEOUT` stays at 30s,
`CURLOPT_LOW_SPEED_LIMIT` stays at 1024 B/s, `http_request_timeout`
stays at 300s.

## Background

The `0-http-request-timeout.php` mu-plugin was introduced in #1407 (May
2025, hard 60s timeout) and refined in #1913 (October 2025, replaced
hard timeout with progress-based watchdog). Both PRs were correct for
their motivation: plugin/theme downloads on slow connections. The #1913
design — abort if transfer stays below 1 KB/s for 30 consecutive seconds
— fails fast on dead mirrors while letting working slow downloads
(Jetpack 33 MB on 3G) complete.

Since October 2025, AI-powered WordPress plugins (Data Machine,
Intelligence, and others) have become a common Studio use case.
Non-streaming `wp_remote_post` calls to LLM providers produce zero bytes
on the socket until the model emits its first token. On flagship models
with long prompts, that first token commonly takes 30 to 90 seconds. The
watchdog fires before any content is delivered, aborting with cURL error
28.

See #3118 for the full reproduction and context.

## Side effects of the change

| Use case | Before (30s) | After (120s) |
|---|---|---|
| Plugin/theme download on fast internet | works | works |
| Plugin/theme download on slow internet (Jetpack-on-3G test from #1913)
| works (bytes flow >1 KB/s) | works (same) |
| Dead mirror (0 B/s forever) | aborts in 30s | aborts in 120s |
| Hung connection (bytes delivered, then stall) | aborts 30s after stall
| aborts 120s after stall |
| AI API request with <30s TTFB | works | works |
| **AI API request with 30-120s TTFB** | **aborts with cURL 28** |
**works** |
| AI API request with >120s TTFB | aborts | aborts via 300s
`http_request_timeout` outer cap |

The only observable behavior change: AI API requests that were dying now
complete. The only regression is that a truly hung connection takes 90
seconds longer to detect. The 300-second outer cap still catches fully
broken requests. This is a small DX trade-off for an uncommon failure
mode, in exchange for first-class AI API support.

No new escape-hatch filter, no URL-based discrimination, no new
configuration knob. The one default value moves to a number that makes
both use cases work.

## Testing Instructions

### Existing #1913 regression test (plugin download on throttled
connection)

Per Bernardo's original methodology:

1. Install Network Link Conditioner (from Apple's Additional Tools for
Xcode)
2. Set throttling to a slow profile (e.g., \"3G\" or \"Edge\")
3. In Studio, navigate to `/wp-admin/plugin-install.php`
4. Install the Jetpack plugin (33 MB)
5. Expected: completes successfully on throttled connection (may take
several minutes)

Should pass identically before and after this change — the
slow-but-working download produces far more than 1 KB/s throughout, so
the low-speed watchdog never triggers regardless of whether the
threshold is 30s or 120s.

### New test case (slow-TTFB AI API request)

1. Install a plugin that uses `wp_remote_post` to call
`https://api.openai.com/v1/chat/completions` (e.g., Data Machine or any
ai-http-client consumer).
2. Configure it to use a flagship OpenAI model (`gpt-5.4` or similar).
3. Send a non-streaming request with a large prompt (~20 KB system
prompt + long user message designed to trigger reasoning).
4. Expected before: fails with cURL error 28 (\"Operation too slow. Less
than 1024 bytes/sec transferred during the last 30 seconds\") on
requests where OpenAI's TTFB exceeds 30 seconds.
5. Expected after: completes normally for any TTFB up to 120 seconds;
fails via the 300s outer cap beyond that.

### Dead-connection regression test

1. `wp eval
'var_dump(wp_remote_get(\"https://example.invalid/never-responds\"));'`
2. Expected: times out in approximately 30s (connect phase — not
affected by this change).

For a case that reaches the low-speed watchdog specifically, you'd need
a server that accepts the connection but then sends zero bytes
indefinitely (uncommon). That case would now take 120s to detect instead
of 30s.

### Verifying the change is live in WASM

```php
// Run via wp eval on a Studio site after restarting it:
global $wp_filter;
foreach ( $wp_filter['http_api_curl']->callbacks as $prio => $cbs ) {
    foreach ( $cbs as $cb ) {
        if ( $cb['function'] instanceof Closure ) {
            $r = new ReflectionFunction( $cb['function'] );
            $f = $r->getFileName();
            $lines = file( $f );
            echo implode( '', array_slice( $lines, $r->getStartLine() - 1, $r->getEndLine() - $r->getStartLine() + 1 ) );
        }
    }
}
```

Expected output after restart should include `CURLOPT_LOW_SPEED_TIME,
120`.

## What this isn't

- **Not an escape-hatch filter.** Shouldn't require opt-in. Defaults
should just work for both use cases.
- **Not URL discrimination.** No hardcoded list of AI endpoints.
- **Not removing the mu-plugin.** #1913's protection is legitimate; this
tunes it.
- **Not touching the 300s outer cap.** Untouched. Separate concern if
ever raised.

## AI assistance disclosure

Per WordPress's [AI
Guidelines](https://make.wordpress.org/ai/handbook/ai-guidelines/),
disclosing meaningful AI assistance on this contribution:

- I used Claude Code to research the history of the mu-plugin (tracing
through #1407 and #1913), evaluate the side effects of possible
adjustments (raising the time vs. lowering the limit vs. an escape-hatch
filter), and draft the commit message and PR body.
- The engineering decision (raise time threshold to 120s rather than add
a filter or discriminate by URL) was mine after reviewing the options
with Claude Code.
- I tested the fix end-to-end on a live Studio site running the Data
Machine plugin, including verifying the compiled mu-plugin contains the
new value via PHP reflection on the runtime closure, and re-running the
daily memory compaction task against flagship gpt-5.4 to confirm
completion.
- The original cURL 28 reproduction that motivated this fix was observed
during real Data Machine usage earlier in the same session.

All substantive design decisions and the testing were my own; AI was
used for investigation, pattern recognition, and drafting.

## Pre-merge Checklist

- [x] Have you checked for TypeScript, React or other console errors?

Co-authored-by: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants