Skip to content

Commit 90945ae

Browse files
committed
Improve logic, focus solely on 429 errors. Update readme file as well.
1 parent fa72d47 commit 90945ae

File tree

2 files changed

+95
-66
lines changed

2 files changed

+95
-66
lines changed

docs/i18n.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Internationalization (i18n)
22

3-
The `pup i18n` command can be used do fetch language files from a GlotPress instance for your project. To enable this
3+
The `pup i18n` command can be used to fetch language files from a GlotPress instance for your project. To enable this
44
command, you must configure the `i18n` section of your `.puprc` file.
55

66
## The bare minimum
@@ -41,7 +41,53 @@ will download language files to an alternate location:
4141
}
4242
```
4343

44-
## All options
44+
## Command-line options
45+
46+
You can adjust the retry and rate-limit handling behavior when running `pup i18n`:
47+
48+
| Option | Type | Default | Description |
49+
|--------|------|---------|-------------|
50+
| `--retries` | `int` | `3` | Number of retries per translation file on failure (minimum 1, maximum 10). Use this to control how aggressive retries should be. |
51+
| `--delay` | `int` | `2` | Base delay in seconds between retries and for HTTP 429 rate-limit backoff (minimum 1). Increase this if you're hitting rate limits frequently. |
52+
| `--batch-size` | `int` | `3` | Batch size for grouping downloads. Used for progress visibility and logging (minimum 1). This does not affect concurrency; downloads are sequential. |
53+
| `--root` | `string` || Optional. Run the command from a different directory. |
54+
55+
### Rate-limit handling
56+
57+
When WordPress.org returns HTTP 429 (Too Many Requests), the `pup i18n` command handles it intelligently:
58+
59+
**Backoff strategy:**
60+
- Uses exponential backoff with multipliers `[16, 31, 91, 151]` applied to the base `--delay`
61+
- 1st 429: `delay × 16` (e.g., 2s × 16 = 32s)
62+
- 2nd 429: `delay × 31` (e.g., 2s × 31 = 62s)
63+
- 3rd 429: `delay × 91` (e.g., 2s × 91 = 182s)
64+
- 4th 429: `delay × 151` (e.g., 2s × 151 = 302s)
65+
- Additional 429s: Use the same multiplier as the 4th (capped at highest in the schedule)
66+
67+
**Server hints:**
68+
- If the response includes a `Retry-After` header (numeric seconds), the command respects it BUT caps it at the computed backoff wait time
69+
- Ensures we never wait longer than our backoff schedule allows
70+
- If `Retry-After` is an HTTP-date format, it is ignored and the backoff schedule is used instead
71+
72+
**Retry consumption:**
73+
- Each HTTP 429 consumes one retry attempt from `--retries`
74+
- Non-429 failures (4xx/5xx errors, invalid responses) also consume retries and use the base `--delay`
75+
- Once `--retries` are exhausted, the download fails for that translation file
76+
77+
**Example waits:**
78+
- With `--delay=2 --retries=3`: Up to 3 attempts (0, 1, 2); if all are 429s: 32s + 62s + 182s = ~4.6 minutes total
79+
- With `--delay=2 --retries=10`: Up to 10 attempts; if first 4 are 429s: 32s + 62s + 182s + 302s = ~9.6 minutes, then 5 more retries with base 2s delay
80+
81+
**Example commands:**
82+
```bash
83+
# Conservative: fewer retries, longer delay between attempts
84+
pup i18n --retries=2 --delay=3
85+
86+
# Aggressive: more retries, shorter delay (use with caution on rate-limited APIs)
87+
pup i18n --retries=5 --delay=1
88+
```
89+
90+
## Configuration file options
4591

4692
| Config option | Type | Description |
4793
|-----------------------------|---|---------------------------------------------------------------------------------------------------------------------------------------------------|

src/Commands/I18n.php

Lines changed: 47 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@
1313

1414
class I18n extends Command {
1515
/**
16-
* Number of retries per translation file (for non-429 failures).
16+
* Number of retries per translation file.
1717
*
1818
* @var int
1919
*/
2020
protected $retries = 3;
2121

2222
/**
23-
* Base delay in seconds for backoff and between retries.
23+
* Base delay in seconds between retries and for HTTP 429 backoff.
2424
*
2525
* @var int
2626
*/
@@ -33,16 +33,9 @@ class I18n extends Command {
3333
*/
3434
protected $batch_size = 3;
3535

36-
/**
37-
* Maximum number of HTTP 429 retries per translation file.
38-
*
39-
* @var int
40-
*/
41-
protected $max_http_429_retries = 4;
42-
4336
/**
4437
* Backoff multipliers for HTTP 429 rate limit errors.
45-
* Index corresponds to the 429 attempt count (0-indexed).
38+
* Index corresponds to the 429 occurrence count (0-indexed).
4639
* Applied as: delay * multiplier.
4740
*
4841
* @var int[]
@@ -71,7 +64,7 @@ protected function execute( InputInterface $input, OutputInterface $output ) {
7164
$config = App::getConfig();
7265
$io = $this->getIO();
7366
$root = $input->getOption( 'root' );
74-
$this->retries = max( 1, min( 10, (int) ( $input->getOption( 'retries' ) ?? 3 ) ) );
67+
$this->retries = max( 1, min( 5, (int) ( $input->getOption( 'retries' ) ?? 3 ) ) );
7568
$this->delay = max( 1, (int) ( $input->getOption( 'delay' ) ?? 2 ) );
7669
$this->batch_size = max( 1, (int) ( $input->getOption( 'batch-size' ) ?? 3 ) );
7770
$i18n = $config->getI18n();
@@ -114,30 +107,30 @@ protected function get_default_client_options() {
114107
}
115108

116109
/**
117-
* Extracts wait time from Retry-After header if present.
118-
* Respects server hint but caps it to the fixed backoff schedule.
110+
* Extracts the wait time from the Retry-After header if present.
111+
* Respects the server hint but caps it to our backoff schedule for that attempt.
119112
*
120113
* @param \Psr\Http\Message\ResponseInterface $response
121-
* @param int $default_wait Default wait time in seconds.
114+
* @param int $backoff_wait The computed backoff wait time.
122115
*
123-
* @return int Recommended wait time in seconds.
116+
* @return int The wait time in seconds.
124117
*/
125-
protected function get_retry_after_delay( $response, $default_wait ) {
118+
protected function get_wait_time_for_429( $response, $backoff_wait ) {
126119
$retry_after = $response->getHeaderLine( 'Retry-After' );
127120

128121
if ( ! $retry_after ) {
129-
return $default_wait;
122+
return $backoff_wait;
130123
}
131124

132-
// Retry-After can be seconds (numeric) or HTTP-date string.
125+
// Retry-After can be numeric seconds or an HTTP-date; parse numeric only.
133126
if ( is_numeric( $retry_after ) ) {
134127
$server_wait = (int) $retry_after;
135-
// Use the smaller of server hint or our backoff; defer to server if more conservative.
136-
return max( 1, min( $server_wait, $default_wait ) );
128+
// Use the server hint but cap at our backoff (don't wait longer than we're willing to).
129+
return max( 1, min( $server_wait, $backoff_wait ) );
137130
}
138131

139-
// If it's an HTTP-date, we'd need to parse it; for simplicity, use default.
140-
return $default_wait;
132+
// HTTP-date format is complex to parse; fall back to backoff schedule.
133+
return $backoff_wait;
141134
}
142135

143136
/**
@@ -228,8 +221,8 @@ protected function download_language_files( I18nConfig $i18n_config ): int {
228221
}
229222

230223
/**
231-
* Synchronously downloads and saves a translation with deterministic retry logic.
232-
* Handles both regular retries and HTTP 429 backoff.
224+
* Synchronously downloads and saves a translation with retry logic.
225+
* Retries consume the standard retry budget; 429 responses use smarter delay logic.
233226
*
234227
* @param Client $client
235228
* @param \stdClass $options
@@ -244,57 +237,47 @@ protected function download_language_files( I18nConfig $i18n_config ): int {
244237
protected function download_and_save_translation_sync( $client, $options, $translation, $format, $project_url ) {
245238
$io = $this->getIO();
246239
$translation_url = "{$project_url}/{$translation->locale}/{$translation->slug}/export-translations?format={$format}";
240+
$http_429_count = 0;
247241

248-
// Outer loop: regular retries for non-429 failures.
249242
for ( $tried = 0; $tried < $this->retries; $tried++ ) {
250-
$http_429_count = 0;
251-
252-
// Inner loop: 429 retries with exponential backoff.
253-
while ( $http_429_count < $this->max_http_429_retries ) {
254-
$response = $client->request( 'GET', $translation_url );
255-
$status_code = $response->getStatusCode();
256-
$body = (string) $response->getBody();
257-
$body_size = strlen( $body );
243+
$response = $client->request( 'GET', $translation_url );
244+
$status_code = $response->getStatusCode();
245+
$body = (string) $response->getBody();
246+
$body_size = strlen( $body );
247+
248+
// Handle HTTP 429 (Too Many Requests) with smarter delay.
249+
if ( 429 === $status_code ) {
250+
// Use the backoff multiplier for this occurrence (0-indexed).
251+
$multiplier = $this->http_429_backoff_multipliers[ $http_429_count ] ?? 151;
252+
$backoff_wait = $this->delay * $multiplier;
253+
$wait_time = $this->get_wait_time_for_429( $response, $backoff_wait );
258254

259-
// Handle HTTP 429 (Too Many Requests).
260-
if ( 429 === $status_code ) {
261-
$multiplier = $this->http_429_backoff_multipliers[ $http_429_count ];
262-
$base_wait = $this->delay * $multiplier;
263-
$wait_time = $this->get_retry_after_delay( $response, $base_wait );
264-
265-
$io->writeln(
266-
"<fg=yellow>Rate limited (HTTP 429) on {$translation->slug}. Waiting {$wait_time}s before retry (attempt " . ( $http_429_count + 1 ) . '/' . $this->max_http_429_retries . ")...</>",
267-
OutputInterface::VERBOSITY_VERBOSE
268-
);
255+
$io->writeln(
256+
"<fg=yellow>Rate limited (HTTP 429) on {$translation->slug}. Waiting {$wait_time}s before retry...</>",
257+
OutputInterface::VERBOSITY_VERBOSE
258+
);
269259

270-
sleep( $wait_time );
271-
$http_429_count++;
272-
continue;
273-
}
260+
sleep( $wait_time );
261+
$http_429_count++;
262+
continue;
263+
}
274264

275-
// Check for valid response.
276-
if ( 200 !== $status_code || $body_size < 200 ) {
265+
// Check for valid response (non-429 case).
266+
if ( 200 !== $status_code || $body_size < 200 ) {
267+
// Non-429 failure: use standard delay and retry.
268+
if ( $tried < $this->retries - 1 ) {
277269
$io->writeln(
278-
"<fg=red>Invalid response from {$translation_url} (status: {$status_code}, size: {$body_size})</>" ,
270+
"<fg=red>Invalid response from {$translation_url} (status: {$status_code}, size: {$body_size}). Retrying...</>",
279271
OutputInterface::VERBOSITY_VERBOSE
280272
);
281-
break; // Exit 429 loop; will retry with regular retry logic.
273+
sleep( $this->delay );
282274
}
283-
284-
// Save the translation file and return on success.
285-
$this->save_translation_file( $body, $options, $translation, $format );
286-
return;
275+
continue;
287276
}
288277

289-
// If we get here, we either got a non-429 error or exhausted 429 retries.
290-
// Try again with regular retry delay (if retries remaining).
291-
if ( $tried < $this->retries - 1 ) {
292-
$io->writeln(
293-
"<fg=yellow>Retrying {$translation->slug} after {$this->delay}s...</>",
294-
OutputInterface::VERBOSITY_VERBOSE
295-
);
296-
sleep( $this->delay );
297-
}
278+
// Success: save and return.
279+
$this->save_translation_file( $body, $options, $translation, $format );
280+
return;
298281
}
299282

300283
// All retries exhausted.

0 commit comments

Comments
 (0)