外部サービスへの書き込み操作(以下「外部 API コール」)が未成功・未確定だったときに、レコードが一切記録されないというバグに遭遇しました。エラーで派手に落ちるのではなく、100%静かにスキップされるという、いやらしい表面化のしかたでした。
原因を追っていくと、問題の核心は「外部 API が失敗を 0 という正常値で返していた」ことにありました。null ではなく 0。この一点が、量計算の下流に静かに 0 を流し込もうとしていたのです。
本記事は、このバグの構造と修正、そして「外部値の 0 と null を区別せよ」「量系は > 0 でガードせよ」という普遍的な学びの記録です。
事の発端:未成功のはずが「0件・0.0」で返ってくる
バグの構造はこうでした。外部 API コールが未成功・未確定だったとき、外部 API のレスポンスは次のような値を返していました。
1# 外部APIの未成功時のレスポンス(イメージ)2response.result_count = 0 # 件数 = 03response.result_value = 0.0 # 値 = 0.0コードはこの 0 / 0.0 を有効な値として受け取り、永続化テーブルに「数量 0」のレコードを書こうとしていました。失敗を表す null なら弾けたかもしれませんが、返ってきたのは型としては完全に正常な 0 です。truthy 判定や is not None 判定のすり抜けを起こすのに、これ以上ない値です。
つまり、**「失敗したのに、失敗とは見なされない値」**が下流に流れていた、というのが発端でした。
なぜ問題か:0 は「失敗」と「正常な値」の両方になりうる
null(None)であれば、「値が無い=失敗・未確定」とほぼ一意に解釈できます。ところが 0 は事情が違います。
| 受け取り側の判定 | null の扱い | 0 の扱い | 量計算への影響 |
|---|---|---|---|
if value:(truthy) | 弾ける | すり抜ける(0 は falsy だが…後述) | 文脈次第で危険 |
if value is not None: | 弾ける | すり抜ける | 0 がそのまま下流へ |
if value > 0: | (要 None ガード) | 弾ける | 安全 |
0 は「外部 API が失敗を表すために返した値」かもしれないし、「本当に件数が 0 だった正常な結果」かもしれません。同じ 0 が、文脈によって失敗にも正常にもなりうる——ここが落とし穴です。
そして、数量・金額相当の数値・件数といった「正であるべき量」にとって、0 の混入は単なる無効値ではありません。掛け算・割り算・集計の途中に紛れ込むと、下流全体を 0 埋めする力を持ちます。if value is not None で受けていると、この 0 が検査をすり抜けて、量計算の根元に静かに座ってしまうのです。
救ったのは下流の CHECK 制約だった
幸い、今回のバグは「静かに 0 が保存される」までは至りませんでした。理由は、永続化テーブルのレコード型(dataclass)に、最後の砦があったからです。
1@dataclass2class Record:3 quantity: int4 # ...5 def __post_init__(self):6 if self.quantity <= 0:7 raise ValueError("quantity は正でなければなりません")__post_init__ に quantity > 0(数量は正)という CHECK 制約が入っていました。そのため、数量 0 のレコードを書こうとすると ValueError が送出され、記録に失敗していたのです。
結果として、バグの実害は「未成功時にレコードが記録されず 100% スキップされる」という形で表面化しました。沈黙のうちに 0 埋めデータが保存されるのではなく、明示的に弾かれていたわけです。
これは、この CHECK 制約が暗黙の検出機構として働いていたことを意味します。本来あってはならない不正値(数量 0)が、保存される前に確実に止められていました。「沈黙の失敗」を「可視化された失敗」に変換していた点で、この制約は非常に良い仕事をしていました。
修正:is not None ではなく > 0 の境界ゲートを入れる
修正の方向は明快です。フォールバック値を採用する条件を、「None でなければ採用」から「正の値のときだけ採用」に変えることでした。
1# Before: None でなければ採用 → 0 がすり抜ける2if fallback is not None:3 quantity = fallback4
5# After: 正の値のときだけ採用 → 0 は採用しない6if fallback is not None and int(fallback) > 0:7 quantity = fallback8else:9 quantity = units # 別経路:事前に算出済みの正の数量へフォールバックfallback が 0 のときは採用せず、事前に算出済みの正の数量(units)にフォールバックする > 0 境界ゲートを入れました。
値(金額相当の数値)についても同様です。
1# After: 値も > 0 のときだけ採用2if result_value is not None and float(result_value) > 0:3 amount = result_value4else:5 amount = signal_value # 別の信頼できる値(シグナル側の値)を使う0 のときは、別の信頼できる値(シグナル側の値)を使うようにしました。本番経路では、この別経路のフォールバック値(units)が呼び出し元で必ず正の数量として渡されることをコード上で確認しています(quantity > 0 が保証されます)。これで、未成功時でも下流に正の量が渡り、レコードが正しく記録されるようになりました。
なお、修正コードには「なぜ > 0 判定が必要か」を将来の読み手向けに説明するコメントを添えました。0 を弾く意図がコメントとして残っていないと、後から「この > 0 は冗長では?」と削られて再混入する恐れがあります。意図を言語化して残すことで、同じバグの再混入を抑止しています。
学び:量系は truthy でなく境界で受ける、そして下流に制約を置く
このケースを普遍化すると、2つの設計原則になります。
1つめ:外部値の 0 と null は区別する。
外部 API は「失敗・未確定」を、null ではなく 0 という正常範囲の値で返すことがあります。これを if value:(truthy 判定)や if value is not None(None だけ弾く)で受けると、0 がすり抜けて下流の量計算・金額計算・カウントに 0 埋めとして混入します。数量・金額・件数のような「正であるべき量」は、truthy でも is not None でもなく、明示的な > 0 の境界検証で受けるべきです。
2つめ:下流に > 0 の制約を置き、最後の砦にする。
入力側のガードは漏れることがあります。だからこそ、下流に > 0 の CHECK 制約(DB やデータ型レベル)を置いておくと、それが最後の砦=暗黙の検出機構になります。今回の dataclass の制約のように、不正な 0 が静かに保存されるのを防ぎ、沈黙の失敗を可視化してくれます。「入口でガード」と「出口でガード」の二重化が効きます。
| 防御の層 | 役割 | 今回の具体 |
|---|---|---|
| 入口(境界ゲート) | 不正な 0 を採用しない | if fallback is not None and int(fallback) > 0 |
| 入口(代替経路) | 0 のとき別の正値へ | units / シグナル側の値へフォールバック |
| 出口(CHECK 制約) | 万一すり抜けても保存させない | dataclass の quantity > 0 |
| 文書(コメント) | 意図を残し再混入を防ぐ | 「なぜ > 0 か」の説明 |
運用Tips:AI にフォールバックを書かせるときの指示
この学びは、AI にフォールバック処理を書かせるときの指示にそのまま落とせます。放っておくと、AI は素直に if value is not None: と書きがちだからです。
依頼テンプレートはこうしています。
1## フォールバック処理の実装依頼2
3外部APIのレスポンスからフォールバック値を採用する処理を書く。4
5必須要件:61. 外部値の「0」と「null」は区別すること7 (外部APIは失敗を null ではなく 0 で返すことがある)82. 数量・金額・件数など「正であるべき量」は9 `if value`(truthy)や `is not None` ではなく、10 明示的な `> 0` の境界検証で受けること113. 0 のときは別の信頼できる経路(事前算出済みの正値など)へ12 フォールバックすること134. 下流のデータ型/DBに `> 0` の制約を置き、14 万一の不正な0が静かに保存されないようにすること155. 「なぜ > 0 判定が必要か」をコメントで残し、1 collapsed line
16 後から冗長と判断されて削られないようにすること特に 2 と 5 を明示しておくのが肝です。> 0 ガードは一見すると冗長に見えるので、意図を言語化しておかないと簡単に剥がれます。
まとめ:0 は「無効」とは限らない
null は素直に「値が無い」を意味してくれますが、0 はそう単純ではありません。外部 API にとって 0 は、失敗を表す値にも、正常な結果にもなりえます。
今回の学びをまとめます。
- 外部 API は失敗を
nullではなく0で返すことがある if value(truthy)やis not Noneでは0がすり抜ける- 「正であるべき量」は 明示的な
> 0で受け、0 のときは別の正値へフォールバックする - 下流に
> 0の CHECK 制約を置き、最後の砦=暗黙の検出機構にする - 「なぜ
> 0か」をコメントで残し、再混入を防ぐ
AI にフォールバックを書かせるときは、「外部値の 0 と null は区別し、量系は > 0 でガードせよ」「下流に制約を置いて沈黙の不正値を弾け」と指示します。たった一段の境界検証ですが、これがあるかどうかで、静かに 0 で埋まったデータが本番に流れるか否かが決まります。