【Android Volley】RetryPolicyを適用するとリトライがキャンセルされなかった

# VolleyとRetryPolicy

Volleyにはリトライ機能があります．
Request#setRetryPolicy()で，RetryPolicyインターフェースを実装したクラスを渡すと，ネットワークリソースの取得に失敗したとき，RetryPlicyで定義した処理を経由してリトライしてくれます．
Volleyには元々DefaultRetryPolicyクラスが用意されていますが，少し機能が物足りなかったため，コレを継承して独自にRetryPolicyを作成しました．

DefaultRetryPolicy.java からの変更点は以下の通りです．
特に重要な部分でもないので，読み飛ばしてもらっても構いません．

* サーバエラー時はリトライしない
* リトライまでのインターバルを設ける

だけです．

```java:ExRetryPolicy.java
public class ExRetryPolicy extends DefaultRetryPolicy {

    protected long mInterval = 3000;

    // デフォルトタイムアウト時間
    private static final int CONNECTION_TIMEOUT = 10000;
    private static final int CONNECTION_RETRY_COUNT = 3;

    public ExRetryPolicy(int initialTimeoutMs, int maxNumRetries, float backoffMultiplier) {
        super(initialTimeoutMs, maxNumRetries, backoffMultiplier);
    }

    public ExRetryPolicy() {
        super(CONNECTION_TIMEOUT, CONNECTION_RETRY_COUNT, 1f);
    }

    public ExRetryPolicy(long interval) {
        super(CONNECTION_TIMEOUT, CONNECTION_RETRY_COUNT, 1f);
        mInterval = interval;
    }

    @Override
    public void retry(VolleyError error) throws VolleyError {
        NetworkResponse response = error.networkResponse;
        if (response != null && response.statusCode >= 500 && response.statusCode < 600) {
            // サーバーエラー時はリトライしない
            throw error;
        }
        if (mInterval > 0) {
            try {
                Thread.sleep(mInterval);
            } catch (InterruptedException e) {
            }
        }
        VolleyLog.d("Network Retry count : %d", getCurrentRetryCount());
        super.retry(error);
    }
}
```

# キャンセルされないっていうのはどういうこと？

そもそもVolleyのリクエストキャンセルは，<strong>ネットワーク通信が始まる前</strong>にcancel()メソッドを呼び出さないとキャンセルされません．（ネットワーク通信中にcancel()が呼ばれると，通信が終了後，コールバックしないで終了します．結局通信そのものはキャンセルされていません．）
コードを示すと

```java:BasicNetwork.java#run()
if (request.isCanceled()) {
    request.finish("network-discard-cancelled");
    continue;
}
```

と

```java:ResponseDeliveryRunnable#run()
if (mRequest.isCanceled()) {
    mRequest.finish("canceled-at-delivery");
    return;
}
```

ここですね．
この流れに少し問題があります．

# 動作フローを見てみる

クライアントの電波の調子が悪くて，通信がタイムアウトしたとき，上のExRetryPolicyでネットワークをリトライするフローを見てみましょう．

作成したRequestをRequestQueue#add()でキューに入れると，NetworkDispatcherが拾って通信処理を行います．
この際タイムアウトが発生したため，BasicNetwork.java#performRequest()内でSocketTimeoutException，またはConnectTimeoutExceptionが発生することになります．

```java:BasicNetwork.java
@Override
public NetworkResponse performRequest(Request<?> request) throws VolleyError {
    long requestStart = SystemClock.elapsedRealtime();
    while (true) {
        HttpResponse httpResponse = null;
        byte[] responseContents = null;
        Map<String, String> responseHeaders = new HashMap<String, String>();
        try {
            // 略
        } catch (SocketTimeoutException e) {
            attemptRetryOnException("socket", request, new TimeoutError());
        } catch (ConnectTimeoutException e) {
            attemptRetryOnException("connection", request, new TimeoutError());
        }
    }
}
```

通信がタイムアウトすると attemptRetryOnException() が呼ばれています．
これを見てみると

```java:BasicNetwork.java#attemptRetryOnException()
/**
 * Attempts to prepare the request for a retry. If there are no more attempts remaining in the
 * request's retry policy, a timeout exception is thrown.
 * @param request The request to use.
 */
private static void attemptRetryOnException(String logPrefix, Request<?> request,
        VolleyError exception) throws VolleyError {
    RetryPolicy retryPolicy = request.getRetryPolicy();
    int oldTimeout = request.getTimeoutMs();

    try {
        retryPolicy.retry(exception);
    } catch (VolleyError e) {
        request.addMarker(
                String.format("%s-timeout-giveup [timeout=%s]", logPrefix, oldTimeout));
        throw e;
    }
    request.addMarker(String.format("%s-retry [timeout=%s]", logPrefix, oldTimeout));
}
```

RequestにセットされたRetryPolicyを取得してretry()を呼び出しています．
その後，BasicNetwork.java#performRequest()のwhileは生きていますので，今行われた通信が再度実行される仕組みです．

# あら…

上記のリトライフローにリクエストがキャンセル済みかどうかを評価する部分がありません．
そのため，RetryPolicyを設定すると，リクエストがキャンセルされてもRetryPolicyが「やめていいよ」っていうまで延々とリトライされ続けることになります．

# 解決方法

BasicNetwork#attemptRetryOnExceptionを呼び出している前でキャンセルフラグを評価して，キャンセル済みであればVolleyErrorを投げてあげれば，自動的にリクエストがキャンセルされます．

1. BasicNetwork#performRequest() で例外をthrow
1. NetworkDispatcher#run() 内でキャッチ
1. ExecutorDelivery#postError() 呼び出し
1. ResponseDeliveryRunnable#run() 内でRequest#isCancel()評価
1. true ならRequest#finish()を呼び出してコールバックせずにreturn;

という流れになっています．
<strong>しかし，</strong>上の処理は直接Volleyライブラリのコードを修正しなければいけないため，Volleyが今後アップデートされるとマージする際に注意を払う必要があったり，うっかり上書きして上記の修正を消してしまうことなどが懸念されます．

ですので，独自のRetryPolicyを修正して，そちらでリトライ時のキャンセル処理を実装します．
具体的にはコンストラクタでRequestインスタンスを受け取り，retry()内でRequest#isCancel()を評価します．
リクエストがすでにキャンセル要求済みであれば，そのままVolleyErrorをthrowします．
こうすることで，元のBasicNetworkを弄ること無く，リトライのキャンセルを行うことが可能です．

```java:ExRetryPolicy.java
private Request<?> mRequest;
public ExRetryPolicy(Request<?> request) {
    super(CONNECTION_TIMEOUT, CONNECTION_RETRY_COUNT, 1f);
    mRequest = request;
}

@Override
public void retry(VolleyError error) throws VolleyError {
    NetworkResponse response = error.networkResponse;
    if (response != null && response.statusCode >= 500 && response.statusCode < 600) {
        // サーバーエラー時はリトライしない
        throw error;
    }
    if(mRequest != null && mRequest.isCanceled()) {
        // キャンセル済みならリトライしない（エラー処理されない）
        throw error;
    }

    if (mInterval > 0) {
        try {
            Thread.sleep(mInterval);
        } catch (InterruptedException e) {
        }
    }
    VolleyLog.d("Network Retry count : %d", getCurrentRetryCount());
    super.retry(error);
}
```

以上です．
何か間違っている点があればご指摘ください．