生產環境中調用外部 HTTP 介面時,網路抖動、服務端 5xx、讀寫超時等異常不可避免。本文基於 HttpURLConnection 實戰經驗,系統講解連線/讀取超時設定、異常分類捕獲、重試策略與資源釋放,並提供可直接落地的代碼範本。
URL url = new URL("https://api.example.com/data");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// 關鍵:區分連線超時與讀取超時(單位:毫秒)
conn.setConnectTimeout(5000); // 建立 TCP 連線最長等待 5s
conn.setReadTimeout(15000); // 讀取回應資料最長等待 15s
conn.setRequestMethod("GET");
conn.setDoInput(true); // 允許讀取輸入流
conn.setDoOutput(false); // GET 請求無需輸出流
conn.setUseCaches(false); // 禁用快取
conn.setRequestProperty("Accept", "application/json");
conn.connect();
關鍵區別:
ConnectTimeout:TCP 三次交握階段,受 DNS、防火牆、網路延遲影響。ReadTimeout:從 Server 回傳第一個位元組到流讀完,受業務邏輯、資料量影響。ReadTimeout > ConnectTimeout,且根據下游 SLA 設定(如 99 分位延遲 + 緩衝)。| 異常類型 | 典型原因 | 是否可重試 | 處理建議 |
|---|---|---|---|
| SocketTimeoutException (Connect) | 連線建立超時、防火牆丟包 | ✅ 是 | 指數退避重試,檢查網路/域名解析 |
| SocketTimeoutException (Read) | 下游處理慢、大數據傳輸 | ⚠️ 謹慎 | 冪等介面可重試,非冪等需業務評估 |
| UnknownHostException | DNS 解析失敗 | ✅ 是 | 重試前刷新 DNS 快取或換解析器 |
| ConnectException (Connection refused) | 端口未監聽、服務掛了 | ✅ 是 | 配合健康檢查、熔斷器 |
| IOException (其他) | 網路中斷、SSL 握手失敗 | 視具體原因 | 記錄錯誤碼、告警 |
| HTTP 4xx/5xx | 業務邏輯錯誤/服務端錯誤 | 5xx 可重試,4xx 多不可 | 解析 response body,記錄 traceId |
public class HttpClientUtil {
private static final Logger log = LoggerFactory.getLogger(HttpClientUtil.class);
private static final int MAX_RETRIES = 3;
private static final int BASE_DELAY_MS = 1000;
public static String getWithRetry(String urlStr, int connectTimeout, int readTimeout)
throws IOException {
IOException lastEx = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(connectTimeout);
conn.setReadTimeout(readTimeout);
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.connect();
int status = conn.getResponseCode();
if (status >= 200 && status < 300) {
return readResponse(conn.getInputStream());
} else if (status >= 500 && status < 600) {
// 5xx 丟出異常觸發重試
throw new IOException("HTTP " + status + ": " + conn.getResponseMessage());
} else {
// 4xx 直接拋出業務異常,不重試
String body = readResponse(conn.getErrorStream());
throw new HttpBusinessException(status, body);
}
} catch (SocketTimeoutException | ConnectException | UnknownHostException e) {
lastEx = e;
log.warn("第 {} 次嘗試失敗: {}", attempt + 1, e.getMessage());
} catch (IOException e) {
// 非網路層 IOException(如 5xx 包裝)也重試
lastEx = e;
log.warn("第 {} 次嘗試遇到 IO 異常: {}", attempt + 1, e.getMessage());
} finally {
if (conn != null) conn.disconnect();
}
if (attempt < MAX_RETRIES) {
// 指數退避 + 0-200ms 抖動,避免驚群效應
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 200);
try { Thread.sleep(delay); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("重試被中斷", ie);
}
}
}
throw new IOException("重試 " + MAX_RETRIES + " 次仍失敗", lastEx);
}
private static String readResponse(InputStream is) throws IOException {
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line).append('\n');
return sb.toString();
}
}
}
HttpURLConnection 功能基礎,生產系統強烈建議遷移至:
@Retryable)、負載均衡。ConnectTimeout 與 ReadTimeout,避免無限阻塞。finally 區塊或 try-with-resources 確保連線釋放。