Retry 기능 추가
This commit is contained in:
+174
@@ -0,0 +1,174 @@
|
||||
package com.eactive.eai.adapter.http.client.impl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.hc.client5.http.ConnectTimeoutException;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
|
||||
public class DeadlineAwareRetryExecutor {
|
||||
|
||||
protected static Logger log = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
|
||||
private final CloseableHttpClient httpClient;
|
||||
private final int connectTimeoutMs;
|
||||
private final int responseTimeoutMs;
|
||||
|
||||
public DeadlineAwareRetryExecutor(CloseableHttpClient httpClient,
|
||||
int connectTimeoutMs,
|
||||
int responseTimeoutMs) {
|
||||
this.httpClient = httpClient;
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
this.responseTimeoutMs = responseTimeoutMs;
|
||||
}
|
||||
|
||||
public CloseableHttpResponse execute(HttpUriRequestBase request, HttpContext context, RetryPolicy policy)
|
||||
throws IOException {
|
||||
|
||||
// retry 비활성 → 단순 실행
|
||||
if (policy.isDisabled()) {
|
||||
applyTimeout(request, connectTimeoutMs, responseTimeoutMs);
|
||||
return httpClient.execute(request, context);
|
||||
}
|
||||
|
||||
long totalDeadlineMs = connectTimeoutMs + responseTimeoutMs;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
IOException lastException = null;
|
||||
|
||||
for (int attempt = 0; attempt <= policy.getMaxRetries(); attempt++) {
|
||||
|
||||
// ① 남은 시간 계산
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
long remainingMs = totalDeadlineMs - elapsed;
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
throw new IOException(
|
||||
String.format("전체 deadline 초과 [elapsed=%dms, deadline=%dms]",
|
||||
elapsed, totalDeadlineMs),
|
||||
lastException
|
||||
);
|
||||
}
|
||||
|
||||
// ② 이번 시도의 effective timeout = min(원래 설정, 남은 시간)
|
||||
long effectiveConnect = Math.min(connectTimeoutMs, remainingMs);
|
||||
long effectiveResponse = Math.min(responseTimeoutMs, remainingMs);
|
||||
applyTimeout(request, effectiveConnect, effectiveResponse);
|
||||
|
||||
log.debug("attempt={}, remainingMs={}, effectiveConnect={}, effectiveResponse={}",
|
||||
attempt + 1, remainingMs, effectiveConnect, effectiveResponse);
|
||||
|
||||
// ③ 요청 실행
|
||||
CloseableHttpResponse response = null;
|
||||
boolean doRetry = false;
|
||||
|
||||
try {
|
||||
response = httpClient.execute(request, context);
|
||||
int status = response.getCode();
|
||||
|
||||
if (!policy.shouldRetryOnStatus(request, status)) {
|
||||
// 성공 또는 재시도 불필요 → 호출부에서 close 책임
|
||||
log.debug("non-retryable response status={}, attempt={}", status, attempt + 1);
|
||||
return response;
|
||||
}
|
||||
|
||||
// 재시도 대상
|
||||
log.warn("retryable status={}, attempt={}/{}",
|
||||
status, attempt + 1, policy.getMaxRetries());
|
||||
lastException = new IOException("재시도 가능 상태코드: " + status);
|
||||
doRetry = true;
|
||||
|
||||
} catch (ConnectTimeoutException e) {
|
||||
if (!policy.shouldRetryOnException(e)) throw e;
|
||||
log.warn("{} attempt={}/{}", e.getClass().getSimpleName(),
|
||||
attempt + 1, policy.getMaxRetries());
|
||||
lastException = e;
|
||||
doRetry = true;
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
if (!policy.shouldRetryOnException(e)) throw e;
|
||||
log.warn("{} attempt={}/{}", e.getClass().getSimpleName(),
|
||||
attempt + 1, policy.getMaxRetries());
|
||||
lastException = e;
|
||||
doRetry = true;
|
||||
|
||||
} catch (NoRouteToHostException e) {
|
||||
if (!policy.shouldRetryOnException(e)) throw e;
|
||||
log.warn("{} attempt={}/{}", e.getClass().getSimpleName(),
|
||||
attempt + 1, policy.getMaxRetries());
|
||||
lastException = e;
|
||||
doRetry = true;
|
||||
|
||||
} finally {
|
||||
// 재시도 대상인 경우에만 여기서 close
|
||||
// return된 response는 호출부에서 close 책임이므로 제외
|
||||
if (doRetry && response != null) {
|
||||
closeQuietly(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 마지막 시도였으면 바로 종료
|
||||
if (attempt >= policy.getMaxRetries()) break;
|
||||
|
||||
// ⑤ 백오프 대기 전 남은 시간 재확인
|
||||
long remainingBeforeWait = totalDeadlineMs - (System.currentTimeMillis() - startTime);
|
||||
if (remainingBeforeWait <= policy.getBackoffMs()) {
|
||||
throw new IOException(
|
||||
String.format("백오프 대기 시간 부족 [remaining=%dms, backoff=%dms]",
|
||||
remainingBeforeWait, policy.getBackoffMs()),
|
||||
lastException
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("backoff sleep {}ms before attempt={}", policy.getBackoffMs(), attempt + 2);
|
||||
Thread.sleep(policy.getBackoffMs());
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("retry 대기 중 interrupt 발생", ie);
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException(
|
||||
String.format("최대 재시도 횟수 초과 [maxRetries=%d]", policy.getMaxRetries()),
|
||||
lastException
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// private helpers
|
||||
// ----------------------------------------------------------------
|
||||
private void applyTimeout(HttpUriRequestBase request, long connectTimeoutMs, long responseTimeoutMs) {
|
||||
RequestConfig existing = request.getConfig(); // configureHttpClient()에서 설정한 값
|
||||
|
||||
RequestConfig.Builder builder = (existing != null) ? RequestConfig.copy(existing) // HttpVersion 등 기존 설정 보존
|
||||
: RequestConfig.custom();
|
||||
|
||||
RequestConfig config = builder.setConnectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.setResponseTimeout(responseTimeoutMs, TimeUnit.MILLISECONDS).build();
|
||||
|
||||
request.setConfig(config);
|
||||
log.info("RequestConfig = {}", config);
|
||||
}
|
||||
|
||||
private boolean isSuccess(int status) {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
private void closeQuietly(CloseableHttpResponse response) {
|
||||
try {
|
||||
response.close();
|
||||
} catch (IOException e) {
|
||||
log.debug("response close 실패 (무시)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -12,12 +12,10 @@ import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -31,12 +29,13 @@ import org.apache.commons.lang3.time.StopWatch;
|
||||
import org.apache.hc.client5.http.classic.HttpClient;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpDelete;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPatch;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPut;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPatch;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
@@ -452,7 +451,17 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
logger.debug("[method getRequestHeaders]" + method.getHeaders().toString());
|
||||
logger.debug("[method getURI]" + method.getPath());
|
||||
}
|
||||
try (CloseableHttpResponse response = (CloseableHttpResponse) mclient.execute(method, context)) {
|
||||
|
||||
// executor 생성 (mclient, timeout은 기존 설정 그대로)
|
||||
DeadlineAwareRetryExecutor executor =
|
||||
new DeadlineAwareRetryExecutor((CloseableHttpClient)mclient,
|
||||
vo.getConnectionTimeout(),
|
||||
vo.getTimeout());
|
||||
|
||||
// Properties에서 RetryPolicy 로드
|
||||
RetryPolicy policy = RetryPolicy.from(prop); // 설정 로드 방식에 맞게
|
||||
try (CloseableHttpResponse response = (CloseableHttpResponse) executor.execute(method, context, policy)) {
|
||||
// try (CloseableHttpResponse response = (CloseableHttpResponse) mclient.execute(method, context)) {
|
||||
status = response.getCode();
|
||||
|
||||
//responseMessage = EntityUtils.toByteArray(response.getEntity());
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.eactive.eai.adapter.http.client.impl;
|
||||
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.hc.client5.http.ConnectTimeoutException;
|
||||
import org.apache.hc.core5.http.HttpRequest;
|
||||
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class RetryPolicy {
|
||||
public static final String RETRY_MAX_RETRIES = "RETRY_MAX_RETRIES";
|
||||
public static final String RETRY_BACKOFF_MS = "RETRY_BACKOFF_MS";
|
||||
public static final String RETRY_STATUS_CODES = "RETRY_STATUS_CODES";
|
||||
public static final String RETRY_ON_CONNECT_FAIL = "RETRY_ON_CONNECT_FAIL";
|
||||
public static final String RETRY_ON_TIMEOUT = "RETRY_ON_TIMEOUT";
|
||||
public static final String RETRY_IDEMPOTENT_ONLY = "RETRY_IDEMPOTENT_ONLY";
|
||||
|
||||
protected static Logger log = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
private int maxRetries;
|
||||
private long backoffMs;
|
||||
private Set<Integer> retryCodes;
|
||||
private boolean retryOnConnectFail;
|
||||
private boolean retryOnTimeout;
|
||||
private boolean idempotentOnly;
|
||||
private static Set<String> IDEMPOTENT_METHODS;
|
||||
static {
|
||||
Set<String> methods = new HashSet<>();
|
||||
methods.add("GET");
|
||||
methods.add("HEAD");
|
||||
methods.add("OPTIONS");
|
||||
// methods.add("PUT");
|
||||
// methods.add("DELETE");
|
||||
IDEMPOTENT_METHODS = Collections.unmodifiableSet(methods);
|
||||
}
|
||||
|
||||
private RetryPolicy(int maxRetries, long backoffMs, Set<Integer> retryCodes, boolean retryOnConnectFail,
|
||||
boolean retryOnTimeout, boolean idempotentOnly) {
|
||||
this.maxRetries = maxRetries;
|
||||
this.backoffMs = backoffMs;
|
||||
this.retryCodes = Collections.unmodifiableSet(retryCodes);
|
||||
this.retryOnConnectFail = retryOnConnectFail;
|
||||
this.retryOnTimeout = retryOnTimeout;
|
||||
this.idempotentOnly = idempotentOnly;
|
||||
}
|
||||
|
||||
public static RetryPolicy from(Properties props) {
|
||||
Set<Integer> codes = new HashSet<>();
|
||||
String statusCodes = props.getProperty(RETRY_STATUS_CODES);
|
||||
if (statusCodes != null && !statusCodes.trim().isEmpty()) {
|
||||
for (String code : statusCodes.split(",")) {
|
||||
String trimmed = code.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
codes.add(Integer.parseInt(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new RetryPolicy(getInt(props, RETRY_MAX_RETRIES, 0), getLong(props, RETRY_BACKOFF_MS, 1000L), codes,
|
||||
getBoolean(props, RETRY_ON_CONNECT_FAIL, false), getBoolean(props, RETRY_ON_TIMEOUT, false),
|
||||
getBoolean(props, RETRY_IDEMPOTENT_ONLY, true));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Properties 파싱 헬퍼 (키 누락 / 타입 오류 방어)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private static int getInt(Properties props, String key, int defaultValue) {
|
||||
String value = props.getProperty(key);
|
||||
if (value == null || value.trim().isEmpty())
|
||||
return defaultValue;
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("RetryPolicy 설정값 파싱 실패 [key={}, value={}] → 기본값({}) 사용", key, value, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static long getLong(Properties props, String key, long defaultValue) {
|
||||
String value = props.getProperty(key);
|
||||
if (value == null || value.trim().isEmpty())
|
||||
return defaultValue;
|
||||
try {
|
||||
return Long.parseLong(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("RetryPolicy 설정값 파싱 실패 [key={}, value={}] → 기본값({}) 사용", key, value, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// "Y"/"y" → true, 그 외 → false
|
||||
private static boolean getBoolean(Properties props, String key, boolean defaultValue) {
|
||||
String value = props.getProperty(key);
|
||||
if (value == null || value.trim().isEmpty())
|
||||
return defaultValue;
|
||||
return "Y".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
public boolean isDisabled() {
|
||||
return maxRetries <= 0;
|
||||
}
|
||||
|
||||
public boolean shouldRetryOnStatus(HttpRequest request, int status) {
|
||||
if (idempotentOnly && !isIdempotent(request)) return false;
|
||||
return retryCodes.contains(status);
|
||||
}
|
||||
|
||||
public boolean shouldRetryOnException(Exception e) {
|
||||
if (e instanceof ConnectTimeoutException) return retryOnConnectFail;
|
||||
if (e instanceof SocketTimeoutException) return retryOnTimeout;
|
||||
if (e instanceof NoRouteToHostException) return retryOnConnectFail;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isIdempotent(HttpRequest request) {
|
||||
return IDEMPOTENT_METHODS.contains(request.getMethod());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user