catchErrorの置き場所がNgRx Effectを停止させる仕組み

「一度保存に失敗してから、画面を再読み込みするまで保存が実行されなくなる」という不具合がある。ボタンは押せるし、Actionも発火している。それでもEffectが動かない。エラーログにも何も出ない。

原因が catchError の置き場所であることは、コードを読んでも気づきにくい。catchError は書いてあるからだ。

Effectの購読が続く範囲

前提として、NgRx の Effect は Action のたびに作られるものではない。actions$ を購読し続ける1本のObservableであり、アプリの起動から終了までその購読が維持される。

この前提を踏まえると、次のコードの問題が見えてくる。

catchErrorを外側に置いたEffect
saveOrder$ = createEffect(() =>
  this.actions$.pipe(
    ofType(OrderActions.saveRequested),
    switchMap(({ order }) => this.api.save(order)),
    map(saved => OrderActions.saveSucceeded({ saved })),
    catchError(() => of(OrderActions.saveFailed())),
@Reviewer
この `catchError` は `switchMap` の外側にあります。失敗すると置き換え先の `of(...)` が complete し、Effect のストリーム自体が完了するため、以後 `saveRequested` に反応しなくなります。
), );

catchError は、上流でエラーが起きたときにストリームを別のObservableへ置き換えるOperatorである。ここで置き換え先に指定されているのは of(OrderActions.saveFailed()) で、これは1つ値を出して complete する。

置き換えが起きるのはストリームの最上流から見た位置、つまり actions$ を含む部分である。置き換え先が complete すれば、Effect本体が complete する。購読し続けるはずの1本が終わってしまう。

実際に同じ構造を組んで、1回目を失敗、2回目と3回目を成功させると次のようになる。

外側に catchError    -> [FAILURE_ACTION, COMPLETE]
内側に catchError    -> [FAILURE_ACTION, saved-ok-1, saved-ok-2]

外側に置いた場合、失敗Actionを1つ出した直後に complete し、その後の2回は処理されない。

Comment
@Reviewer: この `catchError` は `switchMap` の外側です。1回失敗するとEffect自体が完了し、以後このActionを処理しなくなります。内側へ移してください。

内側へ移すと何が変わるか

直し方は、catchError を内部Observableの側へ移すことに尽きる。

catchErrorを内側に置いたEffect
saveOrder$ = createEffect(() =>
  this.actions$.pipe(
    ofType(OrderActions.saveRequested),
    switchMap(({ order }) =>
      this.api.save(order).pipe(
        map(saved => OrderActions.saveSucceeded({ saved })),
        catchError(() => of(OrderActions.saveFailed())),
      ),
    ),
  ),
);

内側に置くと、置き換えの対象は this.api.save(order) だけになる。この内部Observableが complete しても、switchMap は次の入力を待ち続ける。actions$ の購読は切れない。

同じことを @ngrx/operatorstapResponse で書くこともできる。成功と失敗の扱いを1か所にまとめられるぶん、内側であることが構造から読み取りやすい。

同じ理由で止まる他のOperator

同じ理由で Effect を止めるOperatorは他にもある。よく見るのは take(1) と、EMPTY への置き換えである。

同じ理由で止まる2つの書き方
// 1度だけ処理したいつもりで置いた take(1)
loadOnce$ = createEffect(() =>
  this.actions$.pipe(
    ofType(ConfigActions.loadRequested),
    switchMap(() => this.api.loadConfig()),
    map(config => ConfigActions.loaded({ config })),
    take(1),
  ),
);

// 失敗を握りつぶすつもりで置いた EMPTY
syncSilently$ = createEffect(() =>
  this.actions$.pipe(
    ofType(SyncActions.requested),
    switchMap(() => this.api.sync()),
    catchError(() => EMPTY),
  ),
);

take(1) を外側に置くと、1件処理した時点でEffectが complete する。「起動時に1回だけ読み込む」意図であれば期待どおりに見えるが、設定の再読み込みが必要になった時点で動かなくなる。

EMPTY への置き換えはさらに分かりにくい。EMPTY は値を出さずに complete するため、失敗したことを示すActionすら流れない。Effectは静かに終了し、ログにも痕跡が残らない。冒頭に挙げた「エラーログにも何も出ない」不具合の正体がこれである。

Comment
@Reviewer: `catchError(() => EMPTY)` で失敗を握りつぶしていますが、これはEffect自体を完了させます。失敗を無視したいだけであれば、内側で捕捉して `EMPTY` を返してください。

なお、catchError を置かずに未処理のまま error を投げた場合も、そのEffectは error で終了する。「捕捉していないと落ちる」ことは知られていても、「外側で捕捉しても止まる」ことは見落とされやすい。

rxMethod に現れる同じ構造

SignalStore の rxMethod も、接続した入力を処理し続ける1本のパイプラインである。構造が同じなので、エラーの扱いも同じ結果になる。

rxMethodで同じ誤りをした実装
withMethods((store, api = inject(UserApi)) => ({
  search: rxMethod<string>(
    pipe(
      debounceTime(300),
      switchMap(keyword => api.search(keyword)),
      tap(users => patchState(store, { users })),
      catchError(() => EMPTY),
    ),
  ),
})),

一度でも通信が失敗すると、以後は入力を変えても検索が走らなくなる。捕捉は switchMap の内側へ置くか、tapResponse を使う。

Effect と rxMethod は別の仕組みだが、「購読され続ける1本のストリームを、外側の置き換えで終わらせてしまう」という構造は共通している。

retry を付けるときの確認

エラー処理の周辺では retry の扱いも合わせて確認したい。

retry が行うのは、error が起きたときの再購読である。失敗する処理を成功させる仕組みではない。引数なしの retry() は回数の上限がないため、恒久的に失敗する相手に対しては再購読を繰り返し続ける。

書き込み処理に付ける場合はもう一段の確認が要る。1回目のリクエストがサーバーに届いて処理された後にレスポンスだけ失敗した場合、再購読は同じ更新をもう一度実行する。回数、待ち時間、対象とするエラーに加えて、その処理が冪等かどうかを確認する。

Comment
@Reviewer: `retry()` に回数の指定がありません。保存処理なので、同じ更新が二重に実行されないことを確認したうえで、回数と対象エラーを絞ってください。

レビュー観点チェックリスト

エラーOperatorの配置を確認する項目
  • catchErrorswitchMap などの内側に置かれているか
  • 外側の catchError で、Effectや rxMethod を終了させていないか
  • catchError(() => EMPTY) で、失敗の痕跡まで消していないか
  • 外側の take(1) が、再実行の必要な処理を1回で止めていないか
  • retry に回数と対象エラーの指定があるか
  • retry を付けた書き込み処理が冪等か

おわりに

catchError があるかどうかだけを見ると、この不具合は見つからない。見るべきなのは、それがどのObservableを置き換えるのかである。

判断の手順は単純にできる。catchError から上流をたどり、actions$ や接続された入力にぶつかるなら、その捕捉はEffect本体を置き換えている。ぶつからずに内部Observableの先頭で止まるなら、正しい位置にある。