My RSS bot burned 2 million tokens overnight because I put a naive RSS summarizer on an hourly free-tier cron with no deduplication, no junk filter, and no empty-response guard. Free-model quotas are enough for a personal project, but only if you write defensive code around those weaknesses.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
How I Shipped an RSS Bot That Burned 2 Million Tokens Overnight
I wanted a Telegram bot that would pull a few technical blogs via RSS, ask a model for a two-sentence summary, and post into a group. The stack was one Python script, one cron job, and one model API. I parsed feeds with feedparser, glued each item's title and link into a prompt, called a free model, then sent the text with the Telegram Bot API.
On my laptop the first version looked healthy: a handful of calls, decent summaries, no drama. I deployed it to a MonkeyCode free server, scheduled cron every hour, and went to bed. The next morning the MonkeyCode dashboard showed token use far above "a few blog posts." Most of the 2 million tokens were pure waste—the same stale RSS items, summarized again and again.
The runtime loop I actually shipped:
- Cron starts a fresh Python process every hour.
-
feedparserdownloads each feed and returns the current window of items (often dozens, including posts from days ago). - For every item, I called the model with title + URL.
- I posted the completion to Telegram—including junk—and when the model returned nothing, I crashed.
That is the free-tier autopsy in one sentence: cheap completions multiplied by an unbounded loop.
Pitfall 1: No Deduplication, So Every Cron Run Re-Summarized the Feed
RSS is a rolling snapshot, not a queue of unseen items. If a source currently lists 50 entries and nobody published overnight, an hourly job still asks the model to summarize all 50. I did not record what I had already processed.
The cost math I should have done before deploy:
- 50 entries × 1 completion each = 50 calls per feed per run
- Hourly cron = 24 runs per day
- One busy feed ≈ 1,200 summary calls per day
- Almost none of those articles were new
Fix: persist seen IDs in SQLite and skip before you spend tokens.
I keep a local SQLite file. entry_id is the primary key. Lookup first; insert after I accept or skip the item.
import sqlite3
from pathlib import Path
DB_PATH = Path("seen.db")
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS seen (entry_id TEXT PRIMARY KEY)"
)
conn.commit()
return conn
def is_seen(conn, entry_id):
row = conn.execute(
"SELECT 1 FROM seen WHERE entry_id = ?", (entry_id,)
).fetchone()
return row is not None
def mark_seen(conn, entry_id):
conn.execute(
"INSERT OR IGNORE INTO seen (entry_id) VALUES (?)",
(entry_id,),
)
conn.commit()
How this compares with the shortcuts I almost used:
| Approach | Survives cron restart? | Token-safe? | Notes |
|---|---|---|---|
In-memory set
|
No | No | New process every hour = full re-summary |
| Text file of IDs | Yes | Mostly | You own locking and duplicate lines |
SQLite PRIMARY KEY
|
Yes | Yes | One indexed read; INSERT OR IGNORE is enough for a single writer |
Use a stable ID: prefer entry.id or entry.guid from feedparser, then the permalink. Do not key on the title—editors change titles.
The order that actually cuts the overnight burn:
- Call
init_db()once at process start. - For each RSS item, resolve
entry_id. - If
is_seen(conn, entry_id),continuewith zero model calls. - Summarize only unseen items.
- After a send, a deliberate skip, or a permanent
SKIP, callmark_seen. If the model call fails transiently, do not mark, so the next hour can retry.
Pitfall 2: Junk RSS Items Cost the Same as Real Posts
Some feeds mix promotional posts into the technical stream. The model does not know which items are garbage. It will spend the same tokens on "how to get rich quick" as on a deep engineering write-up, and it will happily post the ad into your group.
Fix: turn the system prompt into a filter, not only a summarizer. I required the exact string SKIP for promotional, low-quality, or off-topic software-development content.
SYSTEM_PROMPT = """
You are a content filter. Summarize the article in two sentences.
If the article is promotional, low-quality, or unrelated to software development, return exactly: SKIP
"""
Practical details that matter on a free tier:
- Demand the exact token
SKIP. A free-form "this looks like an ad" still becomes a Telegram message and still burns output tokens. - Keep the user payload small. I send title and URL only, not the full article body. That is a quality tradeoff: weaker summaries, far fewer input tokens. If you later fetch full text, this filter becomes more important, because junk plus a long body is how input tokens explode.
- Treat
SKIPas success for dedupe. Mark the ID seen so you never pay to classify the same promo post again.
This is not a perfect classifier. It is cheaper than summarizing garbage and cheaper than moderating the Telegram group by hand.
Pitfall 3: Empty Model Responses Crashed the Bot
Free models sometimes return empty content. Telegram does not allow empty messages, so the send raised. The first time I assumed my code was wrong. It was the model.
Fix: coerce None content to an empty string, then drop SKIP and blanks before you touch Telegram.
def summarize(client, title, url):
response = client.chat.completions.create(
model=os.environ["MODEL_NAME"],
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Title: {title}\nURL: {url}"},
],
)
content = response.choices[0].message.content or ""
if content.strip() == "SKIP" or not content.strip():
return None
return content.strip()
Wire it into the rest of the defensive layer:
- If
summarizereturnsNone, skip Telegram and stillmark_seenso a permanent empty orSKIPcannot loop. - If Telegram rate-limits the bot, back off and finish the run cleanly. After the fixes, most of my remaining failures were Telegram limits, not the model.
- Timeouts need bounded retries. A hung completion that you immediately retry in a tight loop is another way to burn tokens. Cap the retries, then wait for the next cron.
Without the empty check, one bad completion killed the process and left the rest of the feed untouched. With it, the job is boring: skip, log, continue.
After One Week: 80% Fewer Tokens, and Who Should Skip This
I ran the patched bot for a week. Token consumption dropped about 80%. Daily use settled around 300,000 tokens, inside the free quota I had at the time. Success rate moved from 82% to 99%. Most leftover failures came from Telegram API rate limits, not from the model. Headroom was enough for this load. Quotas change; treat official documentation as the source of truth, not this autopsy.
The free-tier boundary is not raw speed. It is whether you will pay the defensive-code tax:
- Deduplicate so cron cannot multiply stale RSS items into 2 million tokens overnight.
- Filter junk in the prompt so promotional posts do not get a full summary.
- Guard empty output so Telegram cannot exception the job out of existence.
- Retry timeouts with a budget; never retry forever.
None of that is complex. All of it has to exist. If you refuse to write it, the free quota becomes a very expensive no-op.
Skip this design when you need:
- Low-latency replies — hourly cron plus a free model is not a chat product.
- Sensitive data — a shared free server is the wrong place.
- 99.99% availability — free infrastructure will not give you that SLO.
It is a fit for personal projects, prototypes, and internal tools, which is exactly the RSS bot I run.
If you are about to hang feedparser on a free-tier cron, add the SQLite seen table and the SKIP/empty guards before the first night. Then open the dashboard the next morning: you want a quiet ~300k-token day, not another free-tier autopsy. Ship the defensive code first.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Top comments (0)