Skip to content

A rate limit is a schedule instruction, not a broken feed - #142

Merged
ralyodio merged 2 commits into
mainfrom
worktree-substack-bind-fix
Aug 19, 2026
Merged

A rate limit is a schedule instruction, not a broken feed#142
ralyodio merged 2 commits into
mainfrom
worktree-substack-bind-fix

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Why

Making the crawler faster today got us http-429 from Substack. The crawler recorded every one as the publisher's fault:

markCrawlFailure sets status='error', increments error_count, walks the backoff ladder — and at ten consecutive failures marks the feed dead. A rate limit is hit by every feed on one backend in the same minute, so this retires a whole platform for our own crawl rate. Substack is a large share of the directory.

Observed in production: 61 of 72 crawl errors in one window were 429s, against feeds that are entirely healthy.

What changed

A 429 (and a 503 that carries Retry-After — the same statement from a server briefly unwilling rather than permanently unable) now reschedules and touches nothing else. status, error_count, last_error and last_success_at are left exactly as they were.

last_fetched_at is deliberately not stamped either. It means "when we last read this publisher", and a throttle is precisely the case where we did not — stamping it would make a feed we have been bounced from all day look freshly crawled on every staleness report.

The server picks the interval, since it is the only party that knows when its limit resets. Retry-After is parsed in both forms RFC 9110 allows (delay-seconds and HTTP-date), floored at a minute so Retry-After: 0 cannot spin, capped at a day so a misread date cannot mothball the feed, and defaulted to 30 minutes when the server names nothing.

That default is much shorter than the error ladder it replaces, on purpose: the feed is healthy and we want it back soon. It is the rate that has to come down, and lengthening one feed's interval is the wrong instrument for that.

Tests

throttle.test.js asserts the negative — that the throttle path touches no health column — which is the actual guarantee, and that an ordinary 404 still counts against the feed so the guard cannot swallow real breakage. retry-after.test.js covers both header forms, the past-date case, and the clamp.

Full suite: 1,115 tests, 0 failures.

Not in this PR

The deeper issue is that politeness is capped per hostname, and every Substack newsletter is a distinct hostname on one backend — so we can burst a single platform without the cap noticing. Capping by registrable domain is the real fix and wants its own change.

ralyodio and others added 2 commits August 19, 2026 17:30
…140)

Every crawl of a feed that publishes a contact address but names nobody has
been failing at the write since author enrichment shipped. In the hour this
was found, 985 of 1,385 crawls errored; the queue stopped draining and
/crawlstats went with it.

`feedContacts` built each contact as `{ url, network }` and dropped the channel
element it came from. Both `feed_links.source` and `author_links.source` are
`not null`, so the statement bound `undefined` -- which the remote libSQL
client will not serialize at all. It throws `Unsupported type of value` before
any SQL runs, with no column named and no row to point at, and the crawl
recorded it as `could not be crawled`: a publisher who looks down.

The population is large and it is not a platform quirk. Any feed with a
`<webMaster>` or `managingEditor` address whose name fails the person test
takes this path -- WordPress and Substack alike, and Substack additionally
names nobody else, so every newsletter on it qualified.

Nothing caught it because the local SQLite driver the tests use binds
`undefined` as null without complaint. The difference only exists on the wire,
so `link-binds.test.js` asserts what the remote client accepts rather than
what a local write happens to survive.

Two fixes, because either alone leaves a hole: contacts now carry `source`
(provenance worth keeping in its own right -- a mailbox from `itunes:owner` is
a stronger claim than one from `webMaster`), and the four link bind sites
default it, so no caller can put an unbindable value in a not-null column
again.

Also: a newsletter whose host fills in the iTunes block is no longer a podcast.
Substack emits `<itunes:owner>` on every publication it serves and nothing else
that looks like a show -- no `itunes:type`, no `podcast:` namespace, image
enclosures rather than audio -- and that one tag filed the whole platform under
/podcasts. This is the correction the video branch already makes: the tag has
to be corroborated by what the feed actually ships. A declared show still
stands on its own, so a podcast that has not released an episode yet keeps its
category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Crawling faster got us 429s from Substack, and the crawler recorded each one as
the publisher's fault: `markCrawlFailure` sets status='error', increments
error_count, walks the backoff ladder, and at ten consecutive failures marks the
feed dead. Every feed on one backend is throttled in the same minute, so this
retires a whole platform for our own crawl rate -- and Substack is a large share
of the directory.

A 429 now reschedules and touches nothing else. `status`, `error_count`,
`last_error` and `last_success_at` are left exactly as they were, and so is
`last_fetched_at`: it means "when we last read this publisher", and a throttle
is precisely the case where we did not. Stamping it would make a feed we have
been bounced from all day look freshly crawled on every staleness report.

503 with a Retry-After is treated the same way. It is the same statement from a
server that is briefly unwilling rather than permanently unable.

The server picks the interval, since it is the only party that knows when its
limit resets. `Retry-After` is parsed in both forms RFC 9110 allows -- a delay
in seconds and an HTTP date -- floored at a minute so `Retry-After: 0` cannot
spin, capped at a day so a misread date cannot mothball the feed, and defaulted
to 30 minutes when the server names nothing. That is deliberately far shorter
than the error ladder it replaces: the feed is healthy and we want it back soon.
It is the *rate* that has to come down, and lengthening one feed's interval is
the wrong instrument for that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 2087c34 into main Aug 19, 2026
3 checks passed
@ralyodio
ralyodio deleted the worktree-substack-bind-fix branch August 19, 2026 20:10
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.

1 participant