Retry 기능 추가

This commit is contained in:
curry772
2026-03-26 10:24:37 +09:00
parent 31817b7ba2
commit 7c45e4225e
3 changed files with 313 additions and 4 deletions
@@ -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);
}
}
}
@@ -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());
}
}