Couchbase Server Java SDK解説：エラー処理の応用


## はじめに

ここでは、下記の記事の続編として、Couchbase Server Java SDKにおける、例外処理の応用を整理します。

https://qiita.com/yoshiyuki_kono/items/f5562565604433d51c13

## エラー処理の応用

### RetryStrategyとRetryReasons

`RetryStrategy`は、`RetryReason`に基づいて、リクエストを再試行するかどうかを決定します。デフォルトでは、SDKには`BestEffortRetryStrategy`が付属しており、再試行可能なエラーが発生すると、成功するかタイムアウトが期限切れになるまでリクエストを再試行します。

SDK 2のには、アプリケーションでの使用を目的とした`FailFastRetryStrategy`が付属しています。
SDK 3にも同梱されていますが、`@Internal`としてマークされています。
`RetryStrategy`のカスタマイズの説明に従って、`BestEffortRetryStrategy`を拡張およびカスタマイズすることをお勧めします。

`RetryReasons`（参照を見てもいいです参考彼らは操作を再試行しまった理由についての洞察を与えているため、セクションを）。`ErrorContext`要求があるためさまざまな理由で複数回再試行されることを確実に可能であるため、前の章で説明したが、リストなどの理由を公開します。そのため、ディスパッチ中にソケットがダウンしたために要求が再試行された場合と、応答が一時的な障害を示したために再試行された場合があります。

デフォルトの動作をニーズに合わせて調整する方法の詳細については、`RetryStrategy`のカスタマイズを参照してください。

## 例外処理

Javaでは、すべての例外は`CouchbaseException`ベースクラスから派生します。
これは、グループ化メカニズムとしても、必要な場合の「すべてをキャッチ」する可能性としても機能します。`ErrorContext`は`context()`ゲッターを定義していますが、一部の例外では`null`になる場合があります。利用可能な場合は、上記のように例外ログ出力に自動的に含まれます。`CouchbaseException`は`RuntimeException`を拡張しており、SDKにはチェック例外(checked exception)は定義されていません。

SDKが透過的に再試行可能なすべての例外をすでに再試行している場合（`RetryStrategy`を調整しない限り）、再試行できない端末例外、またはSDKが独自に決定するのに十分なコンテキストがない場合にのみ残されます。

### ブロッキングAPIでの例外の処理
さまざまtry/catchな戦略を説明するために、単純な例であるKey/Valueを介したドキュメントのロードについて考えてみましょう。

まず、ドキュメントが存在しないと予想しない場合は、`DocumentNotFoundException`を致命的なエラーとして扱う可能性があります。この場合`CouchbaseException`、コールスタックを上に伝播するか、カスタム例外を使用して再スローすることができます（ここでは任意に`DatabaseException`を定義しています）。

```java
// This will raise a `CouchbaseException` and propagate it
GetResult result = collection.get("my-document-id");

// Rethrow with a custom exception type
try {
  collection.get("my-document-id");
} catch (CouchbaseException ex) {
  throw new DatabaseException("Couchbase lookup failed", ex);
}
```

別のケースとして、ドキュメントが存在しない場合は、ドキュメントを作成する必要があることを示している可能性があります。この場合、他のすべてを再スローしながら、`DocumentNotFoundException`を明示的にキャッチして処理できます。

```java
try {
  collection.get("my-document-id");
} catch (DocumentNotFoundException ex) {
  createDocument("my-document-id");
} catch (CouchbaseException ex) {
  throw new DatabaseException("Couchbase lookup failed", ex);
}
```


前述のように、SDKは可能な限り再試行しますが、アプリケーション開発者としての追加のコンテキストがないと、操作が再試行可能かどうかを判断できない場合があります。

たとえば、アプリケーションによっては、特定のドキュメントが1つのアプリによってのみ書き込まれることがわかっているため、失敗した場合にアップサート操作を再試行しても害はありません。

```java
for (int i = 0; i < 10; i++) {
  try {
    collection.upsert("docid", JsonObject.create().put("my", "value"));
    break;
  } catch (TimeoutException ex) {
    // propagate, since time budget's up
    break;
  } catch (CouchbaseException ex) {
    System.err.println("Failed: " + ex + ", retrying.");
    // don't break, so retry
  }
}
```

このコードは、最大10回の試行でドキュメントのアップサートを試みます。このコードはさまざまな方法で改善できますが、再試行のブロックに関する一般的な問題が浮き彫りになります。通常、操作には上限を表す単一のタイムアウトが予想されます。ただし、この場合、常に新しいタイムアウトを発行しているため、個々のタイムアウトは、単一の操作タイムアウトよりもはるかに多くなる可能性があります。

残りのタイムアウトを追跡し、再試行を実行するときに低い値に設定する方法はありますが、高度な再試行が必要な場合は、代わりに次のセクションで説明するリアクティブ再試行を検討することをお勧めします。

### リアクティブな例外処理
リアクティブAPIでのエラーの処理は非常に強力ですが、最初は理解するのが少し複雑です。

コードのブロックと同様に、操作が失敗した場合、通常は次の3つの方法があります。

- リクエスト元にエラーを伝播する
- フォールバックとして別のメソッド/ APIを試す
- 元の操作を再試行


Reactorでは、エラーは、特別に処理されない限り、オペレーターチェーンを介してサブスクライバーまで移動する信号を終了させます。

```java
collection.reactive().get("this-doc-does-not-exist").subscribe(new Subscriber<GetResult>() {

  @Override
  public void onError(Throwable throwable) {
    // This method will be called with a DocumentNotFoundException
  }

  @Override
  public void onSubscribe(Subscription subscription) {
  }

  @Override
  public void onNext(GetResult getResult) {
  }

  @Override
  public void onComplete() {
  }
});
```

最後に`.block()`（の代わりに`.subscribe()`）が呼び出された場合、エラーがスローされ、try/catchブロックでキャッチできます。

通常、ある時点で修正措置を実行するか、操作を再試行します。前者は、`onError*(…​)`で始まるさまざまなリアクターメソッドを介して実現できます。

`onError*(…​)`と`doOnError(…​)`を混同しないようにしてください。
前者はオペレーターのシーケンスを積極的に変更しますが、後者は副作用（ロギングなど）を実行するためにのみ使用する必要があり、シーケンスをまったく変更しません。

次の例では、get操作を実行し`createDocumentReactive`、ドキュメントが存在しない場合に呼び出されるフォールバックメソッドに切り替えます。`get`と`upsert`は異なる戻りタイプを持っているため、この例ではユーザーに返されるものとして、ドキュメントコンテンツのAPIを使い分ける必要があることに注意してください。

```java
Mono<JsonObject> documentContent = collection.reactive().get("my-doc-id").map(GetResult::contentAsObject)
    .onErrorResume(DocumentNotFoundException.class, e -> createDocumentReactive("my-doc-id"));
```

再試行アクションを実行する場合、reactor APIを使用すると、`retryWhen(Retry retrySpec)`APIを介してこれを非常にエレガントに実行できます。再試行仕様はビルダーのようなAPIであり、いつ、どのように、どのくらいの頻度でプロパティを再試行するかを定義できます。

次のコードは`DocumentNotFoundException`を最大5回再試行してからあきらめて、エラーを伝播します。

```java
collection.reactive().get("my-doc-id")
    .retryWhen(Retry.max(5).filter(t -> t instanceof DocumentNotFoundException));
```

`Retry`ビルダーで利用できるオプションは他にもたくさんあります。詳細については、Reactorの公式ドキュメントを参照してください。

`reactor.util.retry`パッケージ の`Retry`クラスを使用することを常にお勧めします。`com.couchbase.client.core.retry.reactor`パッケージ内の`Retry`クラスと混同しないでください。これは非推奨であり、ここでの目的には使用しないでください。

### RetryStrategyのカスタマイズ

カスタム`RetryStrategy`は、グローバルに有効にすることができます。

```java
ClusterEnvironment environment = ClusterEnvironment.builder().retryStrategy(myCustomStrategy).build();
```

または、リクエストごとに適用することもできます。

```java
collection.get("doc-id", getOptions().retryStrategy(myCustomStrategy));
```

どちらのアプローチも有効ですが、ほとんどのユースケースではデフォルトを維持し、リクエストごとにのみオーバーライドすることをお勧めします。

異なる戦略ですべてのリクエストをオーバーライドしていることに気付いた場合は、ローカルに適用するのが理にかなっています。どちらのアプローチでもパフォーマンスに違いはありませんが、リクエストごとにカスタムのものを渡す場合でも、毎回新しいものを作成するのではなく、呼び出し間で共有するようにしてください。

`RetryStrategy`をゼロから実装することも可能ですが、代わりに`BestEffortRetryStrategy`を拡張して、カスタマイズが必要な特定のものだけを処理することを強くお勧めします。

```java
class MyCustomRetryStrategy extends BestEffortRetryStrategy {

  @Override
  public CompletableFuture<RetryAction> shouldRetry(Request<? extends Response> request, RetryReason reason) {
    // ---
    // Custom Logic Here
    // ---

    // Do not forget to call super at the end as fallback!
    return super.shouldRetry(request, reason);
  }
}
```

重要なのは、`return super.shouldRetry(request, reason);`部分で、他のすべてのケースが自動的に処理されるように、フォールバックを省略しないでください。

具体的な実装例として、下記のようにサーキットブレイカー構成を使用して、開回路でフェイルファストを実行したい場合があります。

```java
class MyCustomRetryStrategy2 extends BestEffortRetryStrategy {
  @Override
  public CompletableFuture<RetryAction> shouldRetry(Request<? extends Response> request, RetryReason reason) {
    if (reason == RetryReason.ENDPOINT_CIRCUIT_OPEN) {
      return CompletableFuture.completedFuture(RetryAction.noRetry());
    }

    return super.shouldRetry(request, reason);
  }
}
```

重要なルールの1つは、`shouldRetry`処理をブロックしないことです。これは、ホットコードパスで呼び出され、パフォーマンスに大きな影響を与える可能性があるためです。これが、戻り型として定義されている理由であり、非同期のレスポンス型であることを示します。

再試行の決定を行うためにネットワークまたはファイルシステムを介して外部システムを呼び出す必要がある場合は、別のスレッドからこれを実行し、たとえば`Atomic`を介して通信することをお勧めします。これにより、ホットコードパスでルックアップを実行できます。

`RetryAction`は、あなたがリクエストで何をすべきかを示します。もしも、あなたが`RetryAction.noRetry()`を戻すならば、`Orchestrator`は、その結果、要求をキャンセルし`RequestCanceledException`を発行します。もう1つのオプションは`RetryAction withDuration(Duration duration)`、を介して呼び出すことです。これは、要求を次に再試行する必要がある期間を示します。これにより、要求を再試行する必要があるかどうかだけではなく、また、何時再試行するべきかを示すことができます。

### TIPS: SDKのAPIによる副作用有無の確認など
- `idempotent()`を介してリクエストがべき等であるかどうかを確認することができます。
- `allowsNonIdempotentRetry()`を介して非べき等の再試行が許可されているかどうかを確認することができます。

APIではありませんが、`BestEffortRetryStrategy`の実装をガイダンスとして用いることも考えられます。