From 7c45e4225eaba31e28d586c7ef4dcfa3ae38af8d Mon Sep 17 00:00:00 2001 From: curry772 Date: Thu, 26 Mar 2026 10:24:37 +0900 Subject: [PATCH] =?UTF-8?q?Retry=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DeadlineAwareRetryExecutor.java | 174 ++++++++++++++++++ .../impl/HttpClient5AdapterServiceRest.java | 17 +- .../adapter/http/client/impl/RetryPolicy.java | 126 +++++++++++++ 3 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/eactive/eai/adapter/http/client/impl/DeadlineAwareRetryExecutor.java create mode 100644 src/main/java/com/eactive/eai/adapter/http/client/impl/RetryPolicy.java diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/DeadlineAwareRetryExecutor.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/DeadlineAwareRetryExecutor.java new file mode 100644 index 0000000..a0b8226 --- /dev/null +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/DeadlineAwareRetryExecutor.java @@ -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); + } + } +} diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java index 2b699de..88558bb 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java @@ -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()); diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/RetryPolicy.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/RetryPolicy.java new file mode 100644 index 0000000..99879a2 --- /dev/null +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/RetryPolicy.java @@ -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 retryCodes; + private boolean retryOnConnectFail; + private boolean retryOnTimeout; + private boolean idempotentOnly; + private static Set IDEMPOTENT_METHODS; + static { + Set 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 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 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()); + } +}