Fix plugin download timeouts by using low-speed timeout instead of hard timeout - #1913
Conversation
📊 Performance Test ResultsComparing 301c1f8 vs trunk site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change |
ivan-ottinger
left a comment
There was a problem hiding this comment.
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.
…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.
…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.
…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.
…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.
…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.
…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>



Related issues
Proposed Changes
Testing Instructions
Pre-merge Checklist