Back to list
Webhook の 403 エラーが、3週間分「未特定」メールのアラートパイプラインを停止させた
My Alert Pipeline Dropped Three Weeks of "Unknown" Emails Because a Webhook 403'd
Translated: 2026/4/25 5:47:09 翻訳信頼度: 94.6%
Japanese Translation
私は 15 分ごとに IMAP 受信トレイをチェックする小型の Python スクリプトを実行しています。新しいメールを 3 つの分類に割り当て、それぞれ専用のアラートパスを設けています。顧客の問い合わせ、Stripe の決済通知、そして他のすべてです。
このうち 3 つ目の分類は、3 週間全くアラートを出力していませんでした。私が探していたときだけ気づきました。
ここでは、何が起きたのか、実際にはどのようなバグがあったのか、そしてそれを置換したものを解説します。fan-out アラートパイプラインをご利用いただければ、おそらくリポジトリの中にこのケースのバージョンがあるでしょう。
このスクリプトは、意図的にシンプルに設計されています。IMAP 接続を開き、保存された UID ウォーターマーク以降の新しいメッセージを取得し、送信元と件名に対して正規表現を使って各メッセージを分類します。VIP は別の ping パイプラインにルーティングされます。Stripe と顧客の問い合わせのアラートは、プライベートチャンネルに Discord ボットに投稿されます。3 つ目の分類——「未分類の新規メール」——は、顧客向けのアラートからノイズを分離したいという my 意図から、独自の webhook を経由で別々の Discord チャンネルに「散らばって」送信されるはずでした。
興味深いコードはわずか 30 行でした:
def discord_post(content):
token = discord_token()
if not token:
log(" ! no Discord token, skipping alert")
return False
url = f"https://discord.com/api/v10/channels/{CHANNEL_ID}/messages"
req = urllib.request.Request(
url,
data=json.dumps({"content": content}).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "DiscordBot (email-monitor, 1.0)",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status == 200
except Exception as e:
log(f" ! Discord post failed: {e}")
return False
呼び出し側:
if discord_post(msg_text):
alerts_sent.append(item["uid"])
これを 2 回読みましょう。呼び出し側は、discord_post が True を返すときのみアラートを送ったと記録します。False が返れば次へ進み、再試行もデッドレターキューもないし、どこかであらゆる失敗を浮き彫りにするものもありません。
それがその全部のバグでした。
ある時点——まだ正確な日を特定できない——に、「未特定」チャンネルの webhook が回転させられた、あるいはボットが追放された可能性があります。Discord は 403 を応答させました。urllib は 4xx の場合 HTTPError を投げます。私の `except Exception: return False` はそれを静かに吸収しました。ログには 1 行ごとに ! Discord post failed: HTTP Error 403: Forbidden と出力されました。いくつかの数百行が、私を読まずにいた cron ログの奥深くに埋もれています。
一方、スクリプトは引き続き 15 分ごとに実行され、IMAP UID ウォーターマークを進め、未分類の各メッセージを「既読」としてマークしました。これらのメッセージは Discord に届くことはありませんでした。どのキューにも到達しませんでした。単にウォーターマークを越えて、スクリプトが注意を払う窓の外に出っただけです。
私は、覚えていた特定の冷たいプロモーションを探すために、タグを見かけたが Discord の履歴では見つからず、どのステートファイルでも見つからなかったため気づきました。そのメッセージはまだ Gmail に残っていました。ただ、パイプラインが生成した何ものにも存在しなくなっただけでした。
「webhook が 403 することなかろう」というのは誘惑的な答えです。それは当然です。しかし webhooks は最終的に 403 します。トークンが回転します。チャンネルが削除されます。ボットに権限が剥ぎ取られます。「HTTP ターゲットは失敗しない」という前提でプロダクションのアラートパイプラインを計画するのは、魔法的な思考です。
本当のエラーは例外ハンドラにありました。`except Exception: log(...); return False` という構文は、非常に危険な形です。ネットワークのふらつき、認証エラー、スキーマの不一致、レート制限——あらゆる失敗モードを、呼び出し側は何もできない 2 状態のシグナルへと収縮させます。再試行の予算はありません。デッドレターはありません。「Discord が 1 分間ダウンした」と「このチャンネルは永遠に消滅した」の違いはありません。
重要アラート(Stripe、顧客)については、まだ Discord パスを保持したかったためです。推し通知を希望しているからです。そのチャンネルにはアクティブなトラフィックがあり、そこで 403 が発生したら数日以内に確認できました。しかし「未特定」のバケットは、設計上、低シグナルなものでした。数週間の沈黙は「今週未特定メールなし」と同じように見えました。
私は「未特定」バケットの webhook パスを削除し、これを置換しました:
Original Content
I run a small Python script that polls an IMAP inbox every fifteen minutes and triages new mail into three buckets: customer inquiries, Stripe payment notifications, and everything else. Each bucket has its own alert path.
For three weeks, the third bucket alerted nothing. I didn't notice until I went looking.
Here's what happened, what the bug actually was, and what I replaced it with. If you have any kind of fan-out alerting pipeline, there's probably a version of this waiting in your repo.
The script is boring on purpose. It opens an IMAP connection, fetches new messages since a saved UID watermark, and classifies each message with regex against sender and subject. VIPs route to a separate ping pipeline. Stripe and customer-inquiry alerts post to a Discord bot in a private channel. The third bucket — "unclassified new mail" — was supposed to spray into a separate Discord channel via its own webhook, because I wanted the noise separate from the customer-facing alerts.
The interesting code was about thirty lines:
def discord_post(content):
token = discord_token()
if not token:
log(" ! no Discord token, skipping alert")
return False
url = f"https://discord.com/api/v10/channels/{CHANNEL_ID}/messages"
req = urllib.request.Request(
url,
data=json.dumps({"content": content}).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "DiscordBot (email-monitor, 1.0)",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status == 200
except Exception as e:
log(f" ! Discord post failed: {e}")
return False
Caller:
if discord_post(msg_text):
alerts_sent.append(item["uid"])
Read that twice. The caller only records an alert as sent when discord_post returns True. On False it moves on to the next item — no retry, no dead-letter queue, no surfacing the failure anywhere.
That's the whole bug.
At some point — I still can't pin the exact day — the webhook for the "unknowns" channel either got rotated or the bot got kicked out. Discord started responding with 403. urllib raises HTTPError on 4xx. My except Exception: return False ate it quietly. The log got one line per attempt: ! Discord post failed: HTTP Error 403: Forbidden. A few hundred of them, buried in a cron log I wasn't reading.
Meanwhile the script ran every fifteen minutes, advanced the IMAP UID watermark, and marked each unclassified message as "seen." Those messages never made it to Discord. They never hit any queue. They just moved past the watermark and out of the window the script paid attention to.
I noticed because I went to look for a specific cold pitch I remembered seeing a tag from, couldn't find it in Discord history, then couldn't find it in any state file either. The message was still in Gmail. Just not in anything the pipeline had produced.
It's tempting to say "the webhook should not have 403'd." Sure. But webhooks will eventually 403. Tokens rotate. Channels get deleted. Bots lose permissions. Planning a production alert pipeline around "the HTTP target won't fail" is magical thinking.
The real error was the exception handler. except Exception: log(...); return False is a specific shape of bad — it collapses every failure mode (network blip, auth error, schema mismatch, rate limit) into the same two-state signal the caller can't do anything with. No retry budget. No dead-letter. No difference between "Discord is down for a minute" and "this channel is gone forever."
For critical alerts (Stripe, customers) I still wanted the Discord path, because I want the push. Those channels had active traffic — a 403 there would have shown up in a day. But the "unknown" bucket was low-signal by design; weeks of silence looked identical to "no unknown mail this week."
I deleted the webhook path for the unknowns bucket and replaced it with this:
# atlas-workspace webhook rotated/deleted (was 403); spool to local queue instead.
try:
queue = STATE_DIR / "unknowns-queue.jsonl"
with open(queue, "a") as fh:
fh.write(json.dumps({
"uid": item["uid"],
"from": item["from"],
"subject": item["subject"],
"snippet": item["snippet"][:500],
"ts": datetime.now(timezone.utc).isoformat(),
}) + "\n")
alerts_sent.append(item["uid"])
log(f" · queued unknown uid={item['uid']} → unknowns-queue.jsonl")
except Exception as e:
log(f" ! unknowns-queue write failed: {e}")
A JSONL file. A separate cron reads new lines once an hour and builds me a digest.
This looks like a downgrade. A Discord ping is pushier than a file append. But "pushy" was exactly the wrong property for this bucket. The right property was durable: if nothing reads the file, nothing is lost. If something reads it, it gets the full record, not the 300-character preview Discord was rendering.
The failure mode for a local file write is also much narrower than for an HTTP request. open throws when the disk is full or the filesystem is read-only — both of which I want to blow up the whole script loudly, not silently one line at a time. I didn't wrap this one in the return False swallow. If it throws, the run bails and I see it on the next heartbeat.
Pick the primitive that matches the urgency. Push (webhook, Discord, page) is right when a human or automated consumer must act in minutes. Pull (a file, a queue, a database row) is right when "by end of day" is fine. Conflating them — shoving low-urgency noise through a push primitive — guarantees the push primitive will eventually silently break on exactly the traffic you can't miss.
The tradeoff I accepted: I lose the ambient "someone pitched us" notification in Discord. In exchange, the unknowns bucket is now the only part of the pipeline I actively trust. A file doesn't 403.