一覧に戻る
    CLIコマンド未実装エラーから学ぶ、段階的デバッグと柔軟なパーサー設計
    開発ラボ
    PRこの記事には広告が含まれています

    CLIコマンド未実装エラーから学ぶ、段階的デバッグと柔軟なパーサー設計

    19 分で読める

    はじめに

    Jenkinsパイプラインで監視スクリプトを実行したら、こんなエラーが出ました。

    Error: No such command 'check'.
    script returned exit code 2

    正直、最初は「え、checkコマンドあるはずだけど…」と思いました。でも調べてみると、そもそも実装されていなかった。ここから始まる「コマンド実装→別のエラー発生→パーサー修正」という一連の流れが、意外と学びの多いものだったので書き残しておきます。

    まとまりのない文章になる可能性がありますが、実際の作業ログをベースにしているので、そのまま残します。


    問題の発生

    社内の監視SDKをJenkinsから実行しようとしたところ、以下のエラーが発生しました。

    python3 -m monitoring.cli check --config configs/api-usage.yaml --output reports/result.json
    Error: No such command 'check'.

    パイプラインのJenkinsfileでは check コマンドを使う前提で書かれていましたが、CLIモジュールにこのコマンドが存在しなかったのです。


    原因の調査

    まず、CLIモジュールで利用可能なコマンドを確認しました。

    # cli.py の登録済みコマンド
    cli.add_command(batch_check)
    cli.add_command(logs_check)
    cli.add_command(cost_check)
    cli.add_command(cloudwatch_check)
    # ... など多数
    # しかし 'check' は存在しない

    Jenkinsfileでは check コマンドを呼び出していますが、実装が抜けていたということです。設計書やドキュメントには記載があるのに、実装が追いついていないパターンでした。


    解決策の検討

    ここで迷ったのが、コマンド名をどうするかです。

    選択肢:

    1. check — シンプルだが、他のcheckコマンド(batch_checklogs_checkなど)との一貫性がない
    2. api-usage-check — 既存のコマンド命名規則に沿っている

    既存のコマンドを見ると、xxx_checkxxx-check という命名が多く、機能を明示する形になっていました。一貫性を保つために api-usage-check を採用することにしました。


    CLIコマンドの実装

    Clickを使ってコマンドを実装しました。ポイントは以下の3点です。

    1. 設定ファイルからAPI種別を判定

    @click.command("api-usage-check")
    @click.option(
        "--config", "-c",
        required=True,
        type=click.Path(exists=True, dir_okay=False, readable=True),
        help="監視設定ファイルのパス(YAML形式)",
    )
    @click.option(
        "--output", "-o",
        required=True,
        type=click.Path(dir_okay=False),
        help="結果を出力するJSONファイルのパス",
    )
    def api_usage_check(config: str, output: str) -> None:
        """外部APIの使用量チェックを実行する。"""
        loaded_config = load_config(config, required_keys=["api_type"])
        api_type = str(loaded_config.get("api_type", "")).lower()
    
        if api_type not in {"service_a", "service_b"}:
            raise click.ClickException(
                f"サポートされていないapi_type: {api_type}"
            )

    2. 既存のMonitorクラスを再利用

    新しくロジックを書くのではなく、既存の HTTPAPIMonitor を呼び出す形にしました。これにより、監視ロジックの重複を避けられます。

        monitor = HTTPAPIMonitor()
        result = monitor.check(loaded_config)

    3. 出力フォーマットの統一

    API種別によって取得できるデータが異なりますが、出力形式は統一しました。

        output_data: dict[str, Any] = {
            "status": result.status,
            "message": result.message,
            "timestamp": result.details.get("timestamp"),
        }
    
        # API種別に応じたデータを追加
        if api_type == "service_a":
            api_data = result.details.get("service_a", {})
            output_data.update({
                "usage_total": api_data.get("usage_total"),
                "daily_usage": api_data.get("daily_usage"),
            })

    最初の修正をデプロイ、そして次のエラー

    コマンドを実装してJenkinsで実行したところ、今度は別のエラーが出ました。

    ERROR - checkコマンドでエラーが発生しました: usage_historyがレスポンスに含まれていません。

    ここ、罠です。

    コマンド自体は動くようになりましたが、今度はAPIレスポンスのパース処理でエラーが発生しています。パーサーが usage_history フィールドを必須として検証していましたが、実際のAPIレスポンスにこのフィールドが含まれていなかったのです。


    パーサーの調査

    既存のパーサーを確認すると、以下のような検証ロジックがありました。

    def validate(self, response: Mapping[str, Any]) -> None:
        if "usage_history" not in response:
            raise MonitorError("usage_historyがレスポンスに含まれていません。")

    テストコードを見ると、usage_history フィールドが存在する前提で書かれていました。しかし、実際のAPIは usage_history を返さないケースがあり、代わりにトップレベルに usageTotalForMonthusageTotalPerDay といったフィールドを返していたのです。

    正直ここで詰まりました。

    考えられる原因:

    1. APIの仕様変更があった
    2. テストが実際のAPIレスポンスを反映していなかった
    3. 特定の条件下でのみ usage_history が返される

    過去のIssueを調べましたが、明確な答えは見つかりませんでした。ただ、現実として動いていないので、パーサーを柔軟にする方向で修正することにしました。


    パーサーの修正方針

    迷ったのは、どこまで柔軟にするかです。

    選択肢:

    1. usage_history がない場合はエラーにする(現状維持)
    2. usage_history がない場合は警告を出して、トップレベルのフィールドを使う
    3. usage_history を完全にオプショナルにする

    2番を選びました。理由は以下の通りです。

    • 本来あるべきフィールドがない場合は、ログで把握したい(1番の考え方を一部残す)
    • ただし、代替手段があるならエラーで止めるべきではない(3番の考え方も取り入れる)
    • 将来的にAPIが修正された場合に、警告ログで気づける

    パーサーの修正

    検証ロジックの緩和

    def validate(self, response: Mapping[str, Any]) -> None:
        logger.debug("レスポンスの検証を開始します")
        if not isinstance(response, Mapping):
            raise MonitorError("APIのレスポンス形式が不正です。")
    
        # usage_historyが存在する場合のみ型チェック
        usage_history = response.get("usage_history")
        if usage_history is not None and not isinstance(usage_history, list):
            raise MonitorError("usage_historyは配列形式である必要があります。")
    
        # 存在しない場合は警告ログ
        if usage_history is None:
            logger.warning(
                "usage_historyがレスポンスに含まれていません。"
                "空の配列として扱います。"
            )

    フォールバックロジックの追加

    def parse(self, response: Mapping[str, Any]) -> APIUsageData:
        usage_history_raw = response.get("usage_history", [])
    
        usage_total = 0
        daily_usage = 0
    
        # usage_historyから取得
        for entry in usage_history_raw:
            if "usageTotal" in entry:
                usage_total = int(entry["usageTotal"])
            if "dailyUsage" in entry:
                daily_usage = int(entry["dailyUsage"])
    
        # usage_historyが空の場合、トップレベルを確認
        if not usage_history_raw:
            logger.debug("usage_historyが空のため、トップレベルを確認します")
    
            if "usageTotalForMonth" in response:
                try:
                    usage_total = int(response["usageTotalForMonth"])
                except (TypeError, ValueError):
                    logger.warning("usageTotalForMonthの変換に失敗しました")
    
            if "usageTotalPerDay" in response:
                try:
                    daily_usage = int(response["usageTotalPerDay"])
                except (TypeError, ValueError):
                    logger.warning("usageTotalPerDayの変換に失敗しました")
    
        return APIUsageData(
            usage_total=usage_total,
            daily_usage=daily_usage,
            usage_history=usage_history_raw,
        )

    意外とここが肝です。フォールバックする際も、変換エラーをキャッチして警告ログを出すようにしています。これにより、予期しないレスポンス形式でも処理は継続しつつ、問題があれば後から調査できます。


    テストの修正

    元のテストは usage_history がない場合にエラーを期待していましたが、新しい挙動に合わせて修正しました。

    def test_parse_handles_missing_usage_history() -> None:
        """usage_history欠落時はトップレベルのフィールドを使用する。"""
        parser = APIResponseParser()
    
        result = parser.parse({
            "account_info": {},
            "usageTotalForMonth": 1000,
            "usageTotalPerDay": 50,
        })
    
        assert result.usage_total == 1000
        assert result.daily_usage == 50
        assert result.usage_history == []

    振り返り

    今回の作業で学んだことを整理します。

    1. エラーメッセージを信じて、まず現状を把握する

    「コマンドがない」と言われたら、本当にないか確認する。当たり前のようで、「あるはずだ」という思い込みがあると見落としがちです。

    2. コマンド名は既存の命名規則に従う

    一貫性のないコマンド名は、使う側を混乱させます。今回は check ではなく api-usage-check にしたことで、他のコマンドとの整合性が取れました。

    3. パーサーは「厳格」と「柔軟」のバランスが大事

    必須フィールドがない場合にすぐエラーにするのではなく、代替手段があるなら警告を出して処理を継続する。ただし、ログには残して後から追えるようにしておく。

    4. 実際のAPIレスポンスとテストの乖離に注意

    テストが実際のAPIレスポンスを反映していないと、本番環境で初めて問題が発覚します。可能であれば、実際のレスポンスを記録してテストに反映する仕組みを作りたいところです。


    おわりに

    「コマンドがない」という単純なエラーから始まって、パーサーの設計まで手を入れることになりました。一つのエラーを直したら次のエラーが出る、というのはよくあることですが、その都度「なぜこのエラーが出るのか」を丁寧に追うことで、システム全体の理解が深まります。

    もう行くしかないなと思いながら進めた結果、意外と学びの多い作業になりました。


    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について