ChatGPTやClaude Codeに書かせた価格監視スクリプトをsystemd timerで毎日動かしていたら、ある日から取得が止まっていた。エラーも通知も出ていない。この症状の多くは、AIが出した requests.get() に timeout が無いことが原因です。この記事では、requests timeoutの付け方をAIへの指示文つきで示し、timeout=(5, 30) とsystemdの RuntimeMaxSec= の二段構えで「黙って止まらない定期取得」に直すまでを扱います。

requests.get()にtimeoutが無いとsystemd timerの毎日実行が止まる仕組み

requestsは既定では「無期限に待つ」

requestsは、timeout= を指定しなければ相手の応答をいつまでも待ちます。相手のサーバーが接続を受け付けたまま何も返さない、途中で通信が途切れたまま切断も届かない、といった状況では、スクリプトは例外も出さずに止まったままになります。エラーにならないので、ログにも何も残りません。

systemd timerは「前回が終わっていないと次を起動しない」

systemd timerは、対象のサービスがまだ動いている(active)状態だと、次の時刻が来ても新しく起動しません。二重起動を防ぐための正しい動きですが、前回の実行が無期限に待ち続けていると、その日以降の実行がすべて飛ばされます。

状態を見ると、何日も前から「実行中」のまま残っているのが分かります。

$ systemctl --user status price-watch.service
● price-watch.service - price watch
     Active: active (running) since ...(数日前の日時)

エラーで落ちたのではなく「ずっと動いている」ことが、この症状の見分け方です。cronの場合は逆に、前回が終わっていなくても次が起動するので、待ち続けるプロセスが毎日1本ずつ積み上がっていきます。

AIが書いたコードの本番トラブルは珍しくない

Lightrunの「2026年版 AI駆動エンジニアリング白書」では、QAやステージングを通過したAI生成コードのうち43%が本番環境で手動のデバッグを必要としたと報告されています。AIが提案した修正案が1回のデプロイで完結したケースは0%で、88%の組織が解決までに2〜3回の往復を要していました。New Relicの「The 2026 State of AI Coding Report 日本語版」(2026年7月30日)でも、本番投入後にインシデントが増えたと答えたテクノロジーリーダーは78%です。「AIが書いたから動く」ではなく、読む箇所を決めて確かめるのが前提になります。

ChatGPT・Claude Codeへの最初の指示と、出てきたコードで確認すべき5箇所

最初の指示(よくある書き方)

Pythonで、以下のURLの商品価格を取得して表示するスクリプトを書いてください。
価格は span.price に入っています。毎日1回動かす予定です。
https://example.com/item/1
https://example.com/item/2
通知にはWebhookを使いたいので、キーは sk-xxxx です。

この指示には問題が2つあります。「毎日動かす」と書いていても、止まらないための条件を何も伝えていないこと。そしてキーの実物をAIに渡していることです。認証情報はプロンプトに入れず、「環境変数から読む」とだけ伝えます。

AIが出しがちなコード

import requests
from bs4 import BeautifulSoup

API_KEY = "sk-xxxx"
URLS = [
    "https://example.com/item/1",
    "https://example.com/item/2",
]

def get_price(url):
    try:
        r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})
        soup = BeautifulSoup(r.text, "html.parser")
        return soup.find("span", {"class": "price"}).text
    except:
        pass

for url in URLS:
    print(get_price(url))

手元で1回試すと普通に動くので、問題に気づきにくいコードです。

自分で見つけるための確認箇所

確認箇所

このコードでの問題

見つけ方

timeoutの有無

requests.get() に timeout= が無い。応答が無いと無期限に待つ

grep -n "requests\.\(get\|post\)" price_watch.py で全行を出し、1行ずつ timeout= があるか見る

例外の握りつぶし

except: pass で失敗が None になり、終了コードも0のまま

grep -n "except:" price_watch.py、pass だけの except を探す

アクセス間隔

URLを間隔なしで連続取得している

ループ内に time.sleep() があるか

キーの直書き

API_KEY = "sk-xxxx" がコードに入っている。Gitやバックアップ経由で漏れる

grep -n "KEY\|TOKEN\|sk-" price_watch.py

HTTPエラーの扱い

404や503でもエラーページのHTMLを解析しに行く

raise_for_status() があるか

もう1点、"Mozilla/5.0" はブラウザを装うUser-Agentです。取得先から見て正体が分からないアクセスになるので、自分のスクリプトだと分かる名前と連絡先を名乗る形に直させます。

find("span", {"class": ...}) という書き方自体は動きますが、今は select_one("span.price") のようにCSSセレクタで書くのが一般的です。古い書き方はAIが学習時点のデータから出しやすい部分です。

timeout=(5, 30)を付けさせる指示文と、直った後のコード

直させる指示文

「エラー処理をちゃんとして」のような曖昧な指示では、AIは try/except を足すだけで終わることがよくあります。直してほしい箇所を条件として列挙します。

このスクリプトを、サーバーで毎日無人実行する前提で直してください。条件は次のとおりです。
1. requests の get と post のすべてに timeout=(5, 30) を付ける
   (接続5秒、読み取り30秒)。1箇所も漏らさないこと。
2. requests.Session() を使い、User-Agent は "price-watch/1.0 (+連絡先URL)" にする。
3. res.raise_for_status() で HTTP エラーを例外にする。
4. except: pass は使わない。requests.RequestException と、
   価格の要素が見つからない場合だけを捕まえ、logging でエラーを記録する。
5. 1件でも失敗したら終了コード1で終わる(systemd が失敗と判定できるように)。
6. URL と URL の間に3秒空ける。
7. Webhook の URL はコードに書かず、環境変数 PRICE_WATCH_WEBHOOK から読む。
8. 書き終えたら、requests の呼び出しを全部列挙して timeout の有無を表で示す。

8番が効きます。Lightrunの調査のとおり修正が1回で完結するとは限らないので、AI自身に呼び出し箇所を列挙させ、そのうえで自分でもgrepで突き合わせます。よくある漏れは、get には付けたのに通知用の post に付け忘れるパターンです。

直った後のコード

実行環境はPython 3.11以上を想定しています(python3 -V で確認)。必要なパッケージは次の2つです。

python3 -m venv .venv
.venv/bin/pip install requests beautifulsoup4
.venv/bin/pip show requests beautifulsoup4   # 入ったバージョンを確認
import logging
import os
import sys
import time

import requests
from bs4 import BeautifulSoup

TIMEOUT = (5, 30)   # (接続, 読み取り) 秒
INTERVAL = 3        # URL間の待ち時間(秒)
URLS = [
    "https://example.com/item/1",
    "https://example.com/item/2",
]

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("price_watch")


def fetch_price(session, url):
    res = session.get(url, timeout=TIMEOUT)
    res.raise_for_status()
    soup = BeautifulSoup(res.text, "html.parser")
    tag = soup.select_one("span.price")
    if tag is None:
        raise ValueError(f"価格の要素が見つからない: {url}")
    return tag.get_text(strip=True)


def notify(text):
    webhook = os.environ.get("PRICE_WATCH_WEBHOOK")
    if not webhook:
        log.warning("PRICE_WATCH_WEBHOOK が未設定のため通知しない")
        return
    try:
        requests.post(webhook, json={"text": text}, timeout=TIMEOUT)
    except requests.RequestException as e:
        log.error("通知に失敗: %s", e)


def main():
    session = requests.Session()
    session.headers["User-Agent"] = "price-watch/1.0 (+https://example.com/contact)"
    failed = 0
    for i, url in enumerate(URLS):
        if i:
            time.sleep(INTERVAL)
        try:
            price = fetch_price(session, url)
            log.info("%s %s", url, price)
        except (requests.RequestException, ValueError) as e:
            failed += 1
            log.error("取得失敗 %s: %s", url, e)
    if failed:
        notify(f"価格取得で{failed}件失敗しました")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

ブロックごとの説明

  • 定数(TIMEOUT・INTERVAL):timeout=(5, 30) は「接続が5秒で確立しなければ諦める」「接続後、30秒間データが1バイトも届かなければ諦める」という意味です。タプルで渡すと接続と読み取りを別々に決められます。
  • fetch_price():取得→raise_for_status() でHTTPエラーを例外化→CSSセレクタで価格を取り出します。要素が無いときは None を返さず ValueError にするので、サイトのHTML構造が変わったことにも気づけます。
  • notify():Webhookは環境変数から読みます。通知用の post にも同じtimeoutを付けているのがポイントで、ここで待ち続けると結局止まります。通知自体の失敗はログに残して本処理を巻き込まないようにしています。
  • main():Session で接続を使い回し、URLの間に3秒空けます。失敗を数えて、1件でもあれば終了コード1を返します。これでsystemdやcron側から「失敗した」と判定できます。

一時的な失敗に再試行を足したい場合は、urllib3の Retry を使う方法があります。AIが古い引数名で書きがちな点は method_whitelist削除|AIコード2026年10月 で扱っています。

timeoutが効くかを手元で確かめる

接続だけ受けて何も返さない相手を手元で作れば、待ち続けないことを確認できます。

# ターミナル1:接続を受けて何も返さないサーバー
python3 -c "import socket,time; s=socket.socket(); s.bind(('127.0.0.1',8765)); s.listen(); c,_=s.accept(); time.sleep(600)"

# ターミナル2:30秒ほどで ReadTimeout になれば正しい
python3 -c "import requests; requests.get('http://127.0.0.1:8765', timeout=(5, 30))"

timeout を外して同じことをすると、10分間戻ってこないはずです。これが本番で起きていたことです。

RuntimeMaxSec=で強制終了させるsystemd timerの設定

timeoutだけでは足りない理由

requestsの読み取りタイムアウトは「データが途切れている時間」の上限であって、処理全体の上限ではありません。相手が20秒おきに少しずつデータを返し続ければ、30秒のタイムアウトには一度も当たらず、取得はいつまでも終わりません。DNSの名前解決が長引くケースなど、requestsのtimeoutの外で待つ箇所もあります。そこで、スクリプトの外側からも「この時間を超えたら止める」をかけます。

ユニットファイル

ユーザー単位のsystemdで動かす例です。%h はホームディレクトリ(例:/home/username)に置き換わります。

# ~/.config/systemd/user/price-watch.service
[Unit]
Description=price watch

[Service]
Type=simple
WorkingDirectory=%h/price_watch
EnvironmentFile=%h/price_watch/.env
ExecStart=%h/price_watch/.venv/bin/python price_watch.py
RuntimeMaxSec=600
# ~/.config/systemd/user/price-watch.timer
[Unit]
Description=price watch daily

[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true

[Install]
WantedBy=timers.target
  • RuntimeMaxSec=600:10分を超えて動いていたら強制終了し、失敗として記録します。URL数×(3秒の間隔+取得時間)より十分長い値にしてください。
  • Type=simple:Type=oneshot では RuntimeMaxSec= が効かない(起動完了までの上限は TimeoutStartSec= で決まる)とする説明があり、systemdのバージョンで扱いが異なります。oneshotで書く場合は man systemd.service で手元の版の説明を確認し、必要なら TimeoutStartSec=600 を使います。
  • EnvironmentFile:PRICE_WATCH_WEBHOOK=... を書いた .env を読み込みます。chmod 600 .env にし、Gitで管理しているなら .gitignore に入れます。
  • Persistent=true:サーバーが止まっていた時間帯の回を、起動時に実行します。

有効化と確認

systemctl --user daemon-reload
systemctl --user enable --now price-watch.timer
loginctl enable-linger username        # ログアウト後もユーザーのtimerを動かす

systemctl --user list-timers           # NEXT と LAST が毎日進んでいるか
journalctl --user -u price-watch.service --since today

強制終了が効いたときは、ログに時間切れで止めた旨が出てサービスが failed になります。failed は次の起動を妨げないので、翌日はまた普通に動きます。list-timers の LAST が数日前のまま止まっていたら、まず status で active (running) が残っていないかを見てください。

どこで毎日動かすか:手元のPC・レンタルサーバーのcron・VPSが止まる条件

スクリプトが正しくても、動かす場所が止まれば取得は止まります。場所ごとに「何が起きると止まるか」を把握しておきます。

場所

止まる条件

向いている場合

手元のPC(Mac・Windows)

スリープ中・電源オフの間は実行されない。macOSのlaunchdは起動時に取り逃した回をまとめて実行するが、その時刻には取れない。OSアップデートで権限が変わることもある

取得時刻が多少ずれてもよく、まず試したい段階

共有レンタルサーバーのcron

プロセスの実行時間やメモリに上限があり、超えると事業者側に止められることがある。Pythonのバージョンや入れられるパッケージが事業者に左右される。systemdは使えない

短時間で終わる取得を1日数回動かすだけの場合

VPS

サーバー自体は24時間動くが、ディスクが満杯になる・契約や支払いが切れる・OS更新後の再起動でtimerが有効になっていない、といった管理側の理由で止まる

systemd timerと RuntimeMaxSec= を使いたい場合、処理が長い場合

手元のPCで動かす場合

Macならlaunchd、WindowsならタスクスケジューラでOKです。launchdの登録手順は launchd定期実行はbootstrapで|2026年10月 にまとめています。PCでは RuntimeMaxSec= に相当する仕組みが無いので、スクリプト側の timeout= が唯一の防御になります。

共有レンタルサーバーのcronで動かす場合

cronは前回が終わっていなくても次を起動するので、多重起動を防ぐ flock と、全体の上限を決める timeout コマンドで包みます。どちらも使えるかは which flock timeout で確認してください。

0 9 * * * cd $HOME/price_watch && flock -n /tmp/price_watch.lock timeout 600 .venv/bin/python price_watch.py >> price_watch.log 2>&1

timeout 600 が RuntimeMaxSec=600 と同じ役割です。cronは環境変数をほとんど引き継がないので、Webhookの環境変数はcrontab内で設定するか、スクリプト側で .env を読み込む形にします。

VPSで動かす場合

前の章のsystemd timerの構成がそのまま使えます。止まる原因が「サーバー管理」に移るので、df -h でディスクの空き、再起動後に systemctl --user list-timers でtimerが残っているかを確認する習慣をつけます。失敗時はスクリプトが終了コード1を返して通知を送るので、通知が来ない日=正常と判断できるよう、通知経路が生きているかも月に一度は試しておくと安心です。

取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔

止まらないことと同じくらい、相手のサーバーに迷惑をかけないことが大事です。AIは何も言わなければこの部分を書きません。

  • 利用規約を先に読む:自動取得を禁止しているサイトでは取得しません。公式APIや価格の配信サービスがあるならそちらを使います。
  • robots.txtを確認する:https://example.com/robots.txt で、取得したいパスが Disallow されていないかを見ます。Pythonの urllib.robotparser でスクリプト内から確認することもできます。
  • アクセス間隔を空ける:記事のコードでは1件ごとに3秒空けています。価格のように1日数回で足りる情報なら、頻度を上げる理由はありません。
  • 正体を名乗る:ブラウザを装うUser-Agentではなく、スクリプト名と連絡先を入れます。
  • 制限は回避しない:アクセス制限やログイン、bot対策に当たったら、それは相手の「来ないでほしい」という意思表示です。回避する方法をAIに書かせるのではなく、取得をやめるか、公式の手段を探します。

AIへの指示にも「robots.txtを確認する処理を入れる」「アクセス間隔を空ける」と明記しておくと、毎回指摘する手間が省けます。

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

まとめ

AIに書かせた定期取得が、エラーも出さずある日から止まる原因の多くは、requests.get() に timeout= が無いことです。requestsは既定で無期限に待ち、systemd timerは前回が終わるまで次を起動しないため、1回の固まりで以降の毎日の実行がすべて飛びます。直すときは「requestsの呼び出しすべてに timeout=(5, 30)」「except: pass 禁止」「失敗時は終了コード1」「キーは環境変数」と条件を列挙してAIに指示し、最後は自分でgrepして漏れを確かめます。読み取りタイムアウトは全体の上限ではないので、外側にsystemdの RuntimeMaxSec= かcronの timeout コマンドを重ねます。動かす場所は、試すなら手元のPC、短い処理ならレンタルサーバーのcron、確実に毎日動かすならVPSのsystemd timerが目安です。