DEV Community

Timothy Kelvin
Timothy Kelvin

Posted on

An RSS feed grew to 500 items and my crawler got flagged as broken πŸ“°

Cover feed timeout
I didn't change a line of code. The feed changed, and Google News had been saying no the whole time.

Hi! I'm Tim, a self-taught developer in Nairobi. I build small data tools that run as Apify Actors, serverless jobs that people pay for per result.

Apify runs every public Actor once a day with its default input. The run has to finish within 5 minutes and return at least one result. If it fails three days in a row, the Actor gets an "under maintenance" label on its Store page.

This week one of mine got that label: a news crawler that reads RSS feeds, fetches each new article, pulls out the clean text, and remembers what it has already returned so a scheduled run only gives you what's new.

Over the last 30 days, 24 of its runs passed and 3 timed out.

Let's find out why! πŸ”

⏱️ Reproducing the daily test

The daily test uses the default input, which for this crawler is an empty form. With no sources given, it falls back to four built-in ones: a Google News search plus the TechCrunch, Electrek and Engadget feeds.

So I ran exactly that on my machine and timed it:

Source Items found Articles saved
Google News search 100 0
TechCrunch (keyword filter) feed didn't load from my connection 0
Electrek 500 500
Engadget (keyword filter) 1 1

580 seconds. Nearly twice the limit, and the two big problems are right there in the table.

πŸ“œ Problem 1: the feed was 500 items long

Electrek runs on WordPress, where a feed shows the latest 10 items by default. This one lists 500, and my code never asked how long a feed was. It fetched every item:

for (const source of sources) {
    let items;
    try {
        items = await discover(source);
    } catch (err) {
        log.warning(`Discovery failed for ${source.name}`, { error: (err as Error).message });
        continue;
    }
    for (const item of items) {
        const article = await fetchAndParse(item.url);
        // ...dedupe, save, charge
    }
}
Enter fullscreen mode Exit fullscreen mode

The crawler is polite: it waits at least a second between requests to the same site. That's the right thing to do, but it means 500 articles from one site take at least 500 seconds before anything else happens.

Dedupe didn't help either. It runs on the parsed article (canonical URL, title, publish time and a hash of the body), so even an article the crawler had already seen got fetched again before it could be skipped.

πŸšͺ Problem 2: Google News says no

The Google News source produced 100 links and 0 articles. Here's the start of news.google.com/robots.txt:

User-agent: *
Disallow: /
Allow: /$
Allow: /?
Allow: /home$
Allow: /home?
Allow: /home/
Allow: /nwshp$
Allow: /topics/
Allow: /publications/
Allow: /stories/
...
Enter fullscreen mode Exit fullscreen mode

Everything is disallowed except the homepage and a few HTML sections. The RSS search feed lives under /rss/search, and every article link in it is a news.google.com/rss/articles/... redirect. None of those paths are allowed.

My crawler checked robots.txt before fetching each article, and correctly refused all 100. But it never checked before fetching the feed itself. So it read a feed it wasn't allowed to read, then politely declined every link in it.

By those rules, the Google News source can't return a single article. It only ever cost time.

(Further down, the same file has a block that disallows everything for GPTBot, ClaudeBot, PerplexityBot and a few other AI crawlers by name.)

🐒 Problem 3, found while fixing: timeouts got three retries

While testing the fix, TechCrunch article pages kept timing out from my connection. The fetch helper retried transient failures three times with backoff, and it treated a timeout like any other transient error.

With a 15-second timeout, that's 4 attempts plus backoff: about a minute for one page. A site that's having a bad day could eat the whole 5 minutes on its own.

πŸ”§ The fixes

1. Check only the newest N items per feed

A new maxArticlesPerSource input, default 20. Items are sorted newest first, then cut:

const maxPerSource = Number.isInteger(input.maxArticlesPerSource) && input.maxArticlesPerSource! > 0
    ? input.maxArticlesPerSource!
    : DEFAULT_MAX_ARTICLES_PER_SOURCE; // 20

const newestFirst = (a: { publishedAt: string | null }, b: { publishedAt: string | null }) =>
    (Date.parse(b.publishedAt ?? '') || 0) - (Date.parse(a.publishedAt ?? '') || 0);

if (items.length > maxPerSource) {
    items = [...items].sort(newestFirst).slice(0, maxPerSource);
}
Enter fullscreen mode Exit fullscreen mode

For a monitor that runs on a schedule, the newest 20 is what you want anyway. Anything older was either returned by an earlier run or is old news.

2. Check robots.txt at the door too

Feeds now go through the same robots.txt check as articles, and Google News sources are skipped with a reason instead of failing quietly:

export async function discover(source: Source): Promise<DiscoveredItem[]> {
    if (source.type === 'google-news') {
        throw new Error("Skipped: Google News doesn't allow automated access to its RSS feeds (robots.txt). Add the publishers' own RSS feeds instead.");
    }
    const feedUrl = source.feedUrl;
    if (!(await isAllowedByRobots(feedUrl))) {
        throw new Error(`Skipped: the site's robots.txt doesn't allow automated access to ${feedUrl}`);
    }
    const feed = await fetchFeed(feedUrl);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Google News also left the default sources. Most publishers have their own feed, often at /feed or /rss, and those are the ones to use.

3. One retry for a timeout, and a per-site limit

A page that timed out once gets one more try, not three:

} catch (err) {
    const timedOut = (err as Error).name === 'TimeoutError' || /timeout/i.test((err as Error).message);
    if (attempt === MAX_RETRIES || (timedOut && attempt >= 1)) throw err;
    await sleep(2 ** attempt * 500);
}
Enter fullscreen mode Exit fullscreen mode

And after 2 failed articles from the same site, the rest of that site is skipped for this run:

for (const item of items) {
    const site = siteOf(item.url);
    if ((failuresBySite.get(site) ?? 0) >= MAX_FAILURES_PER_SITE) {
        skippedDisallowedOrFailed++;
        continue;
    }
    // fetch, and on failure: failuresBySite.set(site, failures + 1)
}
Enter fullscreen mode Exit fullscreen mode

The articles aren't lost. They're still in the feed tomorrow, when the site may be answering again.

4. "Nothing new" is a result

There was one more trap waiting. The daily test needs at least one result, but this crawler's whole point is to return only new articles. A second run on the same day, or a quiet news day, could return nothing at all and look broken.

So a run with nothing new now saves one row saying so, and doesn't charge for it:

if (stored === 0) {
    await Actor.pushData({
        title: 'No new articles in this run (not charged)',
        note: skippedDuplicate > 0
            ? `All ${skippedDuplicate} articles found were already returned by an earlier run.`
            : 'The sources returned no articles that could be fetched and read.',
        discovered,
        skippedDuplicate,
        skippedDisallowedOrFailed,
        skippedSources,
        discoveredAt: new Date().toISOString(),
    });
}
Enter fullscreen mode Exit fullscreen mode

If you're scheduling a monitor, an empty result and a broken run look the same from the outside. A row that says why tells them apart.

πŸ§ͺ After

Run Where Time Result
Empty input, first run my machine 82 s 21 articles
Empty input, same storage again my machine 122 s 1 row: "All 21 articles found were already returned by an earlier run."
A Google News + TechCrunch input my machine 38 s 1 row, with the Google News skip reason in it
Empty input Apify 29 s 25 articles (Electrek 20, TechCrunch 3, Engadget 2)

The repeat run was slower because TechCrunch was timing out from my connection. The per-site limit kicked in after two failures, and it still finished well inside the limit. On Apify's servers, TechCrunch answered fine.

🎯 What I learned

  • Inputs you don't control will grow. A feed, an API page, a sitemap. If the loop has no cap, the run time doesn't either.
  • Check robots.txt at the entry point, not just the pages. My crawler was careful about every article and careless about the feed that listed them.
  • A timeout is not a hiccup. Retrying a 15-second timeout three times turns one slow page into a lost minute.
  • "Nothing new" deserves a row. For anything that runs on a schedule, say why the result is empty.
  • A daily health check is a free alarm. The code hadn't changed since August. The world around it did, and the check is what told me.

The crawler is back to finishing in under a minute, and the under-maintenance label should clear after the next daily test.

Top comments (7)

Collapse
 
agentsearchhq profile image
AgentSearch •

The dedupe point is the one I'd fix first. Keying on the feed item's guid or link lets you skip an already-seen article before any fetch, and the parsed-body hash then only has to catch the rarer case of one story showing up under two URLs. Pairing that with a per-source cap, say the newest 20 items per run, would have kept the 500-item feed from eating the whole five minutes even before the robots fix. Good catch on checking robots.txt for the feed itself too, that one is easy to miss.

Collapse
 
timmkal01 profile image
Timothy Kelvin •

Yeah that’s the next fix. the repeat run in the post re-fetched all 21 articles it had already returned, just to throw them away after parsing lol. skipping by guid/link first would cut every one of those. only catch is an edited article won’t come back, but for a mention monitor i think that’s what you want anyway

Collapse
 
agentsearchhq profile image
AgentSearch •

Makes sense for a mention monitor. If you ever do want edits, keep the guid skip but also store each item's updated date (or a hash of title + description) and only re-fetch when that changes. Still no refetch for the unchanged ones.

Thread Thread
 
timmkal01 profile image
Timothy Kelvin •

If you ever do want edits, keep the guid skip but also store each item’s updated date (or a hash of title + description) and only re-fetch when that changes.

Collapse
 
lucas_him_f7e05624dd9cc96 profile image
Lucas Him •

The free 'nothing new' row is useful, but mixing it with articles can confuse downstream CSV consumers. I'd give it a record_type and a separate health stream; the same distinction matters when a BrowserAct Cloud Bot feeds scheduled results into n8n.

Collapse
 
timmkal01 profile image
Timothy Kelvin •

fair point. it has to stay in the main dataset since the platform’s daily check needs at least one row, but it’s the only row without a url, so filtering it out is one condition

Some comments may only be visible to logged-in visitors. Sign in to view all comments.