1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実行は成功、でも誰にも届いていない。無言で失敗する自動化に気づく仕掛け

1
Last updated at Posted at 2026-08-16

この記事は Zenn に投稿したものの再掲です。
https://zenn.dev/reona777/articles/silent-failures-in-automation

教育系の事業所で現場の運営をしています。エンジニアではありません。2026年の春から Claude Code で社内ツールを作り始めて、5か月で6本を本番稼働させました。

前の記事で、本番で壊れた5つの原因を書きました。

その最後に、5つに共通していたこととして「壊れ方が静かでした」と一行だけ書きました。この記事はそこを掘ります。

この夏に私を一番困らせたのは、動かなくなるツールではなく、動いたことになっているツールでした。 GitHub Actions には緑のチェックが付いている。ログにも例外は出ていない。それでも通知が誰にも届いていない。

先に結論です。無言の失敗は、たいてい「落ちないように書かれたコード」から生まれます。 例外を握りつぶして空を返す、条件に合わないものを黙って飛ばす、見つからなければ代わりの値を使う。どれも単体では正しい書き方で、レビューしても引っかかりません。ただしそれらは全部、異常を正常な出力に変換しているという点で共通しています。

そして、この形はAIに書かせたコードで特に出やすいと感じています。理由は最後に書きます。

1. 名簿が取れず全員に届かなかったのに、実行は成功していた

毎日決まった時刻に、翌日の予定をSalesforceから読んで対象者へLINEで送るスクリプトがあります。GitHub Actions で動かしています。

ある日、25名全員に届きませんでした。ワークフローの表示は success です。

原因

LINEの宛先IDは別のところ(GAS)から取っています。その取得関数がこう書かれていました。

def fetch_ids():
    try:
        res = requests.get(GAS_URL, timeout=15)
        return res.json()["students"]
    except Exception:
        return {}

この日、GAS側が一時的に不調でJSONではないものを返しました。res.json() が例外を投げ、except が拾い、空の辞書が返りました

呼び出し側は素直です。全員分の宛先を空の名簿から引いて、全員「宛先なし」に分類し、送信対象が0件になり、正常終了しました。exit code は 0 です。どこにもエラーはありません。

二次被害のほうが厄介でした

このスクリプトは二重送信を防ぐために、送信し終わると「今日は送信済み」というマーカーをリポジトリに push します。

0件送信でも、処理としては最後まで到達しているのでマーカーが push されます。その結果、10分後に走るはずの2回目の実行が「今日は送信済み」と判断してスキップしました。さらに、マーカーがあると手動で再実行してもワークフローの条件を通らないので、本番からの再送手段そのものが塞がりました。

失敗が成功として記録され、その記録がリカバリを封じる。これが一番怖い形です。

直し方

3つ入れました。

def fetch_ids():
    for wait in (3, 6):
        try:
            res = requests.get(GAS_URL, timeout=30)
            res.raise_for_status()
            data = res.json()
            if "students" not in data:
                raise ValueError("返却JSONに students が含まれていません")
            return data["students"]
        except Exception:
            time.sleep(wait)
    raise RuntimeError("名簿の取得に3回失敗しました")
  • 例外を握りつぶさない。 リトライはしても、最後は投げる
  • 「空はありえない」を呼び出し側で宣言する。 予定データはあるのに名簿が0件なら、それは正常な0件ではないので sys.exit(1) で落とす
  • 失敗したらマーカーを残さない。 落ちれば2回目の実行が自動リトライになり、ジョブも failure になって気づけます

一般化すると、「空」を正常値として返す関数は、その空が異常かどうかを呼び出し側しか判断できません。 名簿が0件はありえない、予定が0件はありうる。この区別は関数の中には無い情報なので、呼び出し側に書くしかありませんでした。

2. 同じ原因で、メンションがただの文字列になっていた

上の障害を調べている途中で、もう1つ見つかりました。

同じ日に、担当者10名へ送るSlackの一覧投稿は届いていました。ただし、全員のメンションがリンクになっておらず、@山田太郎 というただの文字列で投稿されていました。

原因は同じ空の名簿です。SlackのユーザーIDを引く関数がこうなっていました。

def find_slack_id(name):
    uid = slack_map.get(name)
    return f"<@{uid}>" if uid else f"@{name}"

見つからなければ名前をそのまま出す。投稿は成功し、例外も出ず、文面もほぼ同じに見えます。

これに気づけたのは偶然で、通知を受け取った側が「今日はメンションが飛んでこなかった」と言ったからでした。

一般化すると

フォールバックは、異常を「見た目がほぼ同じ正常」に変換します。

代わりの値を使うこと自体は悪くありません。まずいのは、代わりの値を使ったことがどこにも残らないことです。今は、フォールバックした件数を数えて、1件でもあればログに出すようにしました。

そして運用の知識として1つ増えました。メンションが付いていない投稿を見たら、名簿の取得失敗を疑う。 症状と原因が全く似ていないので、これは覚えておかないと辿れません。

3. 1件だけ、無言で消えた

こちらは、たった1件が届かなかった話です。

予定データの名前は 9/3分[対象者名]種別:枠 という形式で入っていて、そこから正規表現で名前を切り出しています。

m = re.search(r"\[([^\]]+)\]", title)
name = m.group(1) if m else ""
if not name:
    continue

ある日の1件だけ、開き括弧 [ が抜けていました(元データの入力ミスです)。マッチせず、continue で飛ばされ、ログには何も出ませんでした。

なぜ気づけなかったか

このスクリプトには、正常なスキップも存在します。日時未定の予定は送らない仕様で、そちらは「未定のためスキップ」とログに出ます。

つまり、想定しているスキップは記録され、想定していないスキップだけが無言でした。 これは順序が完全に逆です。

見つけ方

件数の突合でした。元データ側で対象日の件数を数えると16件、ログの「N名取得」は14名。差の2件のうち1件は未定(正常)、残る1件がこれでした。

そのあと範囲を調べたら、7月以降の5344件のうち名前が取れないのは101件で、100件は元から除外対象の種別、対象者宛の破損はこの1件だけでした。裏を返せば、100件の正常なノイズに1件の異常が埋もれていたわけです。

直し方

  • 名前が取れないときは ] の手前から推測して、推測フラグを付けて返す
  • 推測した名前は、名簿に完全一致したときだけ送る。 一致しなければ送らずに通知へ回す(別人に届くほうが害が大きいので)
  • 特定できなかった予定は Slack に流して、手動送信を促す

ここで1つ、判断を変えました

最初は「1件でも特定できなければ sys.exit(1) で落とす」つもりでした。ケース1でそう直したばかりだったからです。

ですが、これはやめました。ケース1は全員未送信、こちらは大半が送信済みだからです。 失敗にするとマーカーが残らず、次の実行で送信済みの全員に二重送信されます。

「失敗は失敗として落とす」は、途中まで成功している処理には当てはまりませんでした。 部分的な失敗は、落とすのではなく人に届ける。この2つを分けて考えるようになりました。

4. 送信の失敗が、ログの中だけで完結していた

3を直しているときに気づきました。LINEの送信そのものが失敗したとき、ログに が出るだけで、誰にも通知が飛んでいませんでした。

ログは、見に行かないと読めません。そして毎日成功しているものを毎日見に行く人はいません。

気づけない場所に情報を置くのは、情報を捨てるのとほぼ同じでした。 ここも Slack へ流すようにしました。

通知の宛先は1つにして、飛ぶケースを数え上げる

こうして通知を足していくと、今度は「どこに何が飛ぶのか」が分からなくなります。なので、通知先を1つのチャンネルに固定して、そこへ飛ぶケースを列挙して閉じました。

現在この自動化から通知が飛ぶのは4ケースだけです。

  • 宛先IDが未登録
  • 同報先の宛先が未登録
  • 対象者を特定できない予定
  • 送信そのものの失敗

これにワークフロー自体の失敗を加えた5つが、届かない可能性のある経路の全部です。「気づけないケースが残っていないか」は、数え上げて閉じないと確認できません。

なお、リトライは入れていません。送信APIがタイムアウトしても、相手側では送信済みのことがあるからです。 リトライを足す前に「2回届いていいのか」を決める必要があり、この通知の場合は答えが「よくない」でした。

対比:良い壊れ方もありました

同じ月に、正しく失敗した例もあります。

GAS側のエンドポイントに合言葉による保護を入れた日、それを呼んでいるスクリプトに合言葉を渡し忘れました。 翌日の実行は名簿取得で3回とも失敗し、ジョブが failure になり、31名は未送信のままでした。

被害の大きさは1と同じです。違うのは、その日のうちに気づけたことでした。ケース1の修正(例外を投げる・空を許さない・落ちたらマーカーを残さない)が、ちゃんと効いていました。

未送信そのものは防げていません。防げたのは、未送信に気づかないまま翌日を迎えることのほうです。 自動化の信頼性は、失敗しないことより、失敗したときに人間の目に入るかで決まると思うようになりました。

AIに書かせるとこの形になりやすい

ここまでの4件を並べて気づいたことがあります。無言の失敗を生んだコードは、全部**「落ちないように」書かれていました。**

except Exception:
    return {}        # 落ちない

if not name:
    continue         # 落ちない

return uid or f"@{name}"  # 落ちない

そして、これはAIに書かせたコードで出やすい形です。「エラーハンドリングを入れて」と頼むと、多くの場合こうなります。落ちないコードは、書いた直後にレビューする限りでは丁寧に見えます。

ただし、握りつぶすのは丁寧さではなく通知の削除でした。

理由ははっきりしていて、AIは正常系を書くからです。 仕様として渡すのはたいてい「何をするか」であって、「取れなかったときにどうするか」は書いていません。書いていない部分は「とりあえず落ちない」で埋まります。

なので、頼み方を1つ変えました。作らせるときに、機能とは別に異常時の宛先を必ず指定します。

この処理が失敗したとき、または対象を特定できなかったときは、握りつぶさずに Slack の〇〇へ通知して。ログに出すだけにはしないで。

これだけで、except: return {} が出てくる頻度がかなり下がりました。AIが決められないのは、失敗をどこへ届けるかです。 それは業務側の情報で、コードの中には無いからです。

無言の失敗を疑うときに見る場所

前の記事と同じく、自分が使っている入口を置いておきます。

件数の突合
入力側の件数と、ログに出た処理件数。ここが合わないときだけ調べます。無言のスキップは、これ以外の方法では見つかりませんでした。

成功したのに0件、を疑う
「対象0件で正常終了」は、本当に0件だったのか、取得に失敗して0件になったのかを区別できません。0件がありえない入力を先に決めておきます。

GitHub Actions の success を信用しない
exit code 0 は「最後の行まで到達した」以上の意味を持ちません。何件処理したかをログの最後に必ず出すようにしました。

GAS の実行数ページ
https://script.google.com/home/projects/<scriptId>/executions
ステータスが「完了」でも、期間列が異常に短ければ何もせず抜けた可能性があります。

「いつもと少し違う」という報告
メンションが付いていない、通知が1件だけ来ていない。この手の報告は、症状と原因が全く似ていないので、まず件数を数えるところから入ります。

最後に

作ったツールが動かなくなること自体は、そこまで怖くありませんでした。動かなければ気づけるので、直せばいいだけです。

怖いのは、動いているつもりで数週間過ぎることです。 8月に直した4件のうち、2件は「たまたま受け取る側が言ってくれた」から見つかりました。運が良かっただけで、仕組みで拾えていたわけではありません。

なので今は、機能を1つ足すたびに、それが失敗したときに誰の目に入るかを先に決めるようにしています。作るのはAIがやってくれますが、届く先を決めるのは人間側に残っている作業でした。

ほかに作ったものは GitHub に置いています。

書いている人

エンジニアではありません。勤務先の業務ツールを Claude Code で作って運用していて、作ったものと、壊れたときに直した話を書いています。

Claude Code の実務運用については X(@KouritsuONI)でも書いています。スプレッドシート・GAS・LINE・Salesforce まわりの業務自動化について、ご相談は X のDMからどうぞ。

この記事のシリーズ

Claude Code の実務運用について、順に9本書いています。

  1. 非エンジニアがClaude Codeで社内ツールを6本 本番稼働させるまでにやったこと
  2. AIに作らせたツールが本番で壊れた5つの原因と、直し方
  3. Claude CodeにCONTEXT.mdとSPEC.mdとADRを書かせると、途中で破綻しなくなる
  4. Claude Codeのスラッシュコマンドには、手順ではなく踏んだ罠を書く
  5. Claude Codeのメモリに53本ためて分かった、書く価値のある事実とない事実
  6. 実行は成功、でも誰にも届いていない。無言で失敗する自動化に気づく仕掛け(この記事)
  7. Salesforceの項目は「ある」と「使える」が別だった。外から自動化して踏んだ6つ
  8. Salesforceのパスワードを変えたら、連携が8箇所いっせいに止まった
  9. Salesforceのログインが2027年6月に廃止される。自動化10本を調べたら、直す場所は1つではなかった
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?