Skip to content

HTTrack never retries a 429 or a 503, and ignores Retry-After - #1755

Merged
xroche merged 5 commits into
masterfrom
i1738
Sep 23, 2026
Merged

xroche merged 5 commits into
masterfrom
i1738

Conversation

@xroche

@xroche xroche commented Sep 22, 2026

Copy link
Copy Markdown
Owner

HTTrack never retried a 429 or a 503. The retryable status set in htsparse.c was 408, 409, 500, 502 and 504, and Retry-After was parsed nowhere. Against a local server with --retries=3 the 500 and the 408 were each requested four times, the 429 and the 503 exactly once.

Adding the two codes alone would have been worse than dropping the link. A retry re-records the link and the next free socket takes it, so all four attempts on the 500 landed inside the same second. The wait therefore comes first, reusing the launch gate that already carries -%c and --pause. --max-retry-after caps what the engine obeys, because the server picks the value (-%J, 60s by default). A 503 naming no usable delay stays fatal. Both new fields sit at a struct tail, so the soname does not move.

Review turned up a second defect, which the last commit fixes: the hold swallowed the user's Ctrl-C. --pause does the same on master today, measured at 16.1 seconds, so the guard covers both gates.

Closes #1738

xroche and others added 5 commits September 22, 2026 22:01
The retryable status set in htsparse.c was 408, 409, 500, 502 and 504.
A 429 or a 503 therefore got one request and the link was dropped, and
Retry-After was parsed nowhere. Measured against a local server with
--retries=3: the 500 and the 408 were each requested four times, the
429 and the 503 exactly once.

Adding the two codes on their own would have made it worse. A retry
re-records the link and the next free socket takes it, with no backoff,
so all four attempts on the 500 landed inside the same second. The wait
therefore comes first: back_set_retry_after() withholds every new launch
until the delay is up, through the same non-blocking gate that already
carries -%c and --pause, which both the look-ahead and the main loop
already honour.

The server picks the Retry-After value, so --max-retry-after (-%J,
60 seconds by default) caps what the engine obeys, and the knob itself
stops at an hour. A 503 naming no delay stays fatal, because it is as
likely a host that is down for good and retrying it triples the requests
made to it.

One int at the htsblk tail and one at the httrackp tail, both ABI-stable,
so the soname does not move.

Closes #1738

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Review found that the new hold swallowed the user's Ctrl-C: with
--max-retry-after=20 a SIGINT took 17.3s to be honoured, against 1ms with
the wait waived. back_pluggable_sockets_strict() returned 0 ahead of the
socket arithmetic every wait loop reads, and sig_leave() only sets
opt->state.stop, which nothing on that path looked at.

--pause has the same defect on master today, measured at 16.1s, so the
guard covers both gates rather than only the new one. Both now answer in
tens of milliseconds.

Also from review: the crawl test could not see the HTTP-date form at all,
because its date case was a 429, which retries whether or not the header
parsed. It is a 503 now, joined by a 503 carrying an unusable value, so
dropping the date branch or swapping the parser for atoi() both go red.
A gate self-test covers the hold, the cap and the stop without a signal,
so Windows runs it too.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thing

Under WSL2 the suite runs a native Windows python, whose text-mode stdout
ends every line with CRLF. Test 490 counts requests with
grep -c '^HIT GET /rate.html$', which a trailing CR makes match nothing, so
the leg failed reporting 0 requests while its own dump showed all three.
The engine was right; the counting was not. Pin stdout to LF the way
local-server.py and proxytestlib.py already do.

Also regenerate html/httrack.man.html, which the man-page-sync guard
requires to move whenever man/httrack.1 does.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The number was free when the branch was cut; master has since landed
490 and 491. 231_test-names sees the collision on the merged tree and
reds five legs with it, all from that one cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit 7bbea79 into master Sep 23, 2026
39 checks passed
@xroche
xroche deleted the i1738 branch September 23, 2026 06:55
xroche added a commit that referenced this pull request Sep 23, 2026
…to label one (#1757)

#1755 added `--max-retry-after` to the engine, and no front end offers
it. WinHTTrack cannot even label a field for one, because `lang.def` is
this repo's. `COptionTab4::GetTip()` shows no tooltip at all when
`LANGSEL()` returns the empty string for a key the catalogs lack.

This adds the field to WebHTTrack's Flow control page, next to Retries.
It also adds the two `LANG_` keys, translated in all 30 catalogs, and
the `MaxRetryAfter` row in `winprofile-keys.tsv`. The row names `web` as
its only owner. The other two gain their letter when each lands its own
field, the way `PauseFiles` gained `droid` in #1331.

An empty box passes no flag, so the engine's own 60 seconds apply. A
front end that substituted 60 would write a value nobody chose, which is
the `MaxRate` lesson in `doc/winprofile-ini.md`. Test 493 probes a fresh
session for that before it posts anything.

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP 429 and Retry-After support

1 participant