OKHttp請求超時無效問題記錄(自動重試)
阿新 • • 發佈:2022-03-17
參考:https://www.jianshu.com/p/3ef261ab157c
參考:https://www.jianshu.com/p/89033630ab7a
發現問題
在專案開發中發現,發起網路請求是會一直顯示Loading。但是我們在okhttp初始化的時候已經設定的網路請求超時時間為30s。為什麼會出現這種情況 WTF!最後發現原來是OKHttp的重試機制挖的坑
OKHttp重試機制剖析
OKHttp擁有網路連線失敗時的重試功能:
OkHttp perseveres when the network is troublesome: it will silently recover from common connection problems. If your service has multiple IP addresses OkHttp will attempt alternate addresses if the first connect fails. This is necessary for IPv4+IPv6 and for services hosted in redundant data centers. OkHttp initiates new connections with modern TLS features (SNI, ALPN), and falls back to TLS 1.0 if the handshake fails. 要了解OKHttp的重試機制,我們最關心的就是RetryAndFollowUpInterceptor
RetryAndFollowUpInterceptor
完成。具體我們先從RetryAndFollowUpInterceptor
的#intercept(Chain chian)
方法開始入手
1 public Response intercept(Chain chain) throws IOException { 2 Request request = chain.request(); 3 this.streamAllocation = new StreamAllocation(this.client.connectionPool(), this.createAddress(request.url())); 4 int followUpCount = 0; 5 Response priorResponse = null; 6 //while迴圈 7 while(!this.canceled) { 8 Response response = null; 9 boolean releaseConnection = true; 10 11 try { 12 response = ((RealInterceptorChain)chain).proceed(request, this.streamAllocation, (HttpStream)null, (Connection)null); 13 releaseConnection = false; 14 } catch (RouteException var12) { 15 if(!this.recover(var12.getLastConnectException(), true, request)) { 16 throw var12.getLastConnectException(); 17 } 18 19 releaseConnection = false; 20 continue; 21 } catch (IOException var13) { 22 if(!this.recover(var13, false, request)) { 23 throw var13; 24 } 25 26 releaseConnection = false; 27 continue; 28 } finally { 29 if(releaseConnection) { 30 this.streamAllocation.streamFailed((IOException)null); 31 this.streamAllocation.release(); 32 } 33 34 } 35 36 if(priorResponse != null) { 37 response = response.newBuilder().priorResponse(priorResponse.newBuilder().body((ResponseBody)null).build()).build(); 38 } 39 40 Request followUp = this.followUpRequest(response); 41 if(followUp == null) { 42 if(!this.forWebSocket) { 43 this.streamAllocation.release(); 44 } 45 46 return response; 47 } 48 49 Util.closeQuietly(response.body()); 50 ++followUpCount; 51 if(followUpCount > 20) { 52 this.streamAllocation.release(); 53 throw new ProtocolException("Too many follow-up requests: " + followUpCount); 54 } 55 56 if(followUp.body() instanceof UnrepeatableRequestBody) { 57 throw new HttpRetryException("Cannot retry streamed HTTP body", response.code()); 58 } 59 60 if(!this.sameConnection(response, followUp.url())) { 61 this.streamAllocation.release(); 62 this.streamAllocation = new StreamAllocation(this.client.connectionPool(), this.createAddress(followUp.url())); 63 } else if(this.streamAllocation.stream() != null) { 64 throw new IllegalStateException("Closing the body of " + response + " didn\'t close its backing stream. Bad interceptor?"); 65 } 66 67 request = followUp; 68 priorResponse = response; 69 } 70 71 this.streamAllocation.release(); 72 throw new IOException("Canceled"); 73 }
去掉程式碼片段中的非核心邏輯:
1 //StreamAllocation init... 2 Response priorResponse = null; 3 while (true) { 4 if (canceled) { 5 streamAllocation.release(); 6 throw new IOException("Canceled"); 7 } 8 9 Response response; 10 boolean releaseConnection = true; 11 try { 12 response = realChain.proceed(request, streamAllocation, null, null); 13 releaseConnection = false; 14 } catch (RouteException e) { 15 //socket連線階段,如果發生連線失敗,會統一封裝成該異常並丟擲 16 `RouteException`:通過路由的嘗試失敗了,請求將不會被髮送,此時會嘗試通過呼叫`#recover`來恢復; 17 // The attempt to connect via a route failed. The request will not have been sent. 18 if (!recover(e.getLastConnectException(), false, request)) { 19 throw e.getLastConnectException(); 20 } 21 releaseConnection = false; 22 continue; 23 } catch (IOException e) { 24 //socket連線成功後,發生請求階段時丟擲的各類網路異常 25 // An attempt to communicate with a server failed. The request may have been sent. 26 boolean requestSendStarted = !(e instanceof ConnectionShutdownException); 27 if (!recover(e, requestSendStarted, request)) throw e; 28 releaseConnection = false; 29 continue; 30 } finally { 31 // We're throwing an unchecked exception. Release any resources. 32 if (releaseConnection) { 33 streamAllocation.streamFailed(null); 34 streamAllocation.release(); 35 } 36 }
原來一直在執行while迴圈,Okhttp在網路請示出現錯誤時會重新發送請求,最終會不斷執行
1 catch (IOException var13) { 2 if(!this.recover(var13, false, request)) { 3 throw var13; 4 } 5 6 releaseConnection = false; 7 continue; 8 }
接下來看核心的recover方法:
1 /** 2 * Report and attempt to recover from a failure to communicate with a server. Returns true if 3 * {@code e} is recoverable, or false if the failure is permanent. Requests with a body can only 4 * be recovered if the body is buffered or if the failure occurred before the request has been 5 * sent. 6 */ 7 private boolean recover(IOException e, boolean requestSendStarted, Request userRequest) { 8 streamAllocation.streamFailed(e); 9 10 // The application layer has forbidden retries. 應用層禁止重試則不再重試 11 if (!client.retryOnConnectionFailure()) return false; 12 13 // We can't send the request body again. 如果請求已經發出,並且請求的body不支援重試則不再重試 14 if (requestSendStarted && userRequest.body() instanceof UnrepeatableRequestBody) return false; 15 16 // This exception is fatal. //致命錯誤 17 if (!isRecoverable(e, requestSendStarted)) return false; 18 19 // No more routes to attempt. 沒有更多route發起重試 20 if (!streamAllocation.hasMoreRoutes()) return false; 21 22 // For failure recovery, use the same route selector with a new connection. 23 return true; 24 }在該方法中,首先是通過呼叫
streamAllocation.streamFailed(e)
來記錄該次異常,進而在RouteDatabase
中記錄錯誤的route以降低優先順序,避免下次相同address的請求依然使用這個失敗過的route。如果沒有更多可用的連線線路則不能重試連線。
1 public final class RouteDatabase { 2 private final Set<Route> failedRoutes = new LinkedHashSet<>(); 3 4 /** Records a failure connecting to {@code failedRoute}. */ 5 public synchronized void failed(Route failedRoute) { 6 failedRoutes.add(failedRoute); 7 } 8 9 /** Records success connecting to {@code route}. */ 10 public synchronized void connected(Route route) { 11 failedRoutes.remove(route); 12 } 13 14 /** Returns true if {@code route} has failed recently and should be avoided. */ 15 public synchronized boolean shouldPostpone(Route route) { 16 return failedRoutes.contains(route); 17 } 18 }
接著我們重點再關注isRecoverable
方法:
1 private boolean isRecoverable(IOException e, boolean requestSendStarted) { 2 // If there was a protocol problem, don't recover. 協議錯誤不再重試 3 if (e instanceof ProtocolException) { 4 return false; 5 } 6 7 // If there was an interruption don't recover, but if there was a timeout connecting to a route 8 // we should try the next route (if there is one) 9 if (e instanceof InterruptedIOException) { 10 return e instanceof SocketTimeoutException && !requestSendStarted; 11 } 12 13 // Look for known client-side or negotiation errors that are unlikely to be fixed by trying 14 // again with a different route. 15 if (e instanceof SSLHandshakeException) { 16 // If the problem was a CertificateException from the X509TrustManager, 17 // do not retry. 18 if (e.getCause() instanceof CertificateException) { 19 return false; 20 } 21 } 22 //使用 HostnameVerifier 來驗證 host 是否合法,如果不合法會丟擲 SSLPeerUnverifiedException 23 // 握手HandShake#getSeesion 丟擲的異常,屬於握手過程中的一環 24 if (e instanceof SSLPeerUnverifiedException) { 25 // e.g. a certificate pinning error. 26 return false; 27 } 28 29 // An example of one we might want to retry with a different route is a problem connecting to a 30 // proxy and would manifest as a standard IOException. Unless it is one we know we should not 31 // retry, we return true and try a new route. 32 return true; 33 }
問題解決
可以關閉okhttp的重試,讓retryOnConnectionFailure返回false就好了:
1 sClient = builder.retryOnConnectionFailure(false).build();
更新
該問題 在3.4.2版本已處理
https://github.com/square/okhttp/issues/2756
常見網路異常分析:
UnknowHostException
產生原因:
- 網路中斷
- DNS 伺服器故障
- 域名解析劫持
解決辦法:
- HttpDNS
- 合理的兜底策略
![Uploading image_079055.png . . .]
InterruptedIOException
產生原因:
- 請求讀寫階段,請求執行緒被中斷
解決辦法:
- 檢查是否符合業務邏輯
SocketTimeoutException
產生原因:
- 頻寬低、延遲高
- 路徑擁堵、服務端負載吃緊
- 路由節點臨時異常
解決辦法:
- 合理設定重試
- 切換ip重試
要特別注意: 請求時因為讀寫超時等原因產生的SocketTimeoutException,OkHttp內部是不會重試的
因此如果app層特別關心該異常,則應該自定義intercetors,對該異常進行特殊處理。
SSLHandshakeException
產生原因:
- Tls協議協商失敗/握手格式不相容
- 辦法伺服器證書的CA未知
- 伺服器證書不是由CA簽名的,而是自簽名
- 伺服器配置缺少中間CA(不完整的證書鏈)
- 伺服器主機名不匹配(SNI);
- 遭遇了中間人攻擊。
解決辦法:
- 指定SNI
- 證書鎖定
- 降級Http。。。
- 聯絡SA
SSLPeerUnverifiedException
產生原因:
- 證書域名校驗錯誤
解決辦法:
- 指定SNI
- 證書鎖定
- 降級Http。。。
- 聯絡SA