개발한 서비스 중 commons httpclient 로 서버 <-> 서버 간 rest api 호출하는 부분에 부분에서 간헐적으로 NoHttpResponseException : The target server failed to respond 예외가 발생하기 시작했다. 이를 해결하기위해 자료를 좀 찾아봤더니 HttpClient 4.4에서 존재하던 버그였고 4.4.1에서 해결된 문제( https://issues.apache.org/jira/browse/HTTPCLIENT-1610 )라고 하는데... 개발한 서비스에는 4.5.1을 쓰는데?
현상의 이유는 HTTP/1.1의 Keep-Alive로 인해 httpclient는 통신이 끝난 connection을 종료하지 않고 동일host:port에 대해 동일한 커넥션을 이용하려하기 때문이다.
비록 서버측은 통신이 완료되어 해당 연결을 close 할지라도 client 측은 커넥션 객체가 여전히 열여있고 데이터가 인입되길 기다리고 있게된다. ( close()의 실제 의미는 소켓의 단절이 아닌 "나는 더 이상 보낼 데이터가 없습니다."로 상대측에서는 해당 커넥션을 단절하지 않는 이상 여전히 데이터가 인입될 수 있음을 의미한다. )
이때를 half-closed connection 으로 표현하며 이는 TCP가 그렇게 동작하게끔 설계되었기 때문으로 버그가 아니다. 이런 상황이 되면 JVM상의 connection 객체는 당연히 살아있지만 내부 소켓은 CLOSE_WAIT 상태가 된다.
문제는 httpclient가 이 CLOSE_WAIT 상태에있는 connection 객체를 다시 사용하려고 할때 앞에서 설명한것과 같이 서버 측은 이미 연결을 끊어버렸기 때문에 NoHttpResponseException - The target server failed to respond 예외를 발생한다.
이를 해결하기위해서는 httpclient의 connectionManager에서 통신이 완료된 connection을 적절하게 제거할 필요가 있다.
Spring 4.X 환경에서 commons HttpClient 4.5.x 를 소켓 설정과
KeepAlive 설정등을 포함하여 xml로 설정하는 방법.
PoolingHttpClientConnectionManager를 통하여 CloseableHttpClient 를 사용하는 과정에 ConnectionManager의 closeIdleConnections 설정 통해 특정시간 idle인 커넥션을 종료하고 싶은 경우 java 코드가 아닌 spring xml 설정정으로 bean을 등록하려 할 때.
사용은 당연히
@Autowired
private CloseableHttpClient httpClient;
[code]
<!-- ===================================================================== -->
<!-- ======================= HttpClient 4.5.X ======================== -->
<!-- ===================================================================== -->
<bean id="requestConfigBuilder" class="org.apache.http.client.config.RequestConfig" factory-method="custom">
<property name="socketTimeout" value="10000" />
<property name="connectTimeout" value="12000" />
<property name="connectionRequestTimeout" value="12000" />
</bean>
<bean id="requestConfig" factory-bean="requestConfigBuilder" factory-method="build" />
<bean id="socketConfigBuilder" class="org.apache.http.config.SocketConfig" factory-method="custom">
<!-- 소켓이 연결된후 InputStream에서 읽을때 timeout -->
<property name="soTimeout" value="10000" />
<!-- SO_KEEPALIVE를 활성화 할 경우 소켓 내부적으로 일정시간 간격으로 heartbeat을 전송하여, 비정상적인 세션 종료에 대해 감지.
unix 계열 : /etc/sysctl.conf
windows : \HKEY_LOCAL_MACHINE\SystemCurrentControlSet\Services\TCPIP\Parameters
-->
<property name="soKeepAlive" value="true" />
<!-- 비정상종료된 상태에서 아직 커널이 소켓의 bind정보를 유지하고 있을 때 해당 소켓을 재사용 할 수 있도록 -->
<property name="soReuseAddress" value="true" />
<!-- nagle 알고리즘 적용 여부 -->
<property name="tcpNoDelay" value="true" />
<!-- socket이 close 될 때 버퍼에 남아 있는 데이터를 보내는데 기다려주는 시간(blocked)-->
<property name="soLinger" value="100" />
</bean>
<bean id="poolingHttpClientConnectionManager" class="org.apache.http.impl.conn.PoolingHttpClientConnectionManager" destroy-method="shutdown">
<constructor-arg value="2000" type="long" index="0" /> <!-- pool에 있는 커넥션 제거 idle time -->
<constructor-arg value="MILLISECONDS" type="java.util.concurrent.TimeUnit" index="1" />
<property name="maxTotal" value="60" />
<property name="defaultMaxPerRoute" value="15" />
<property name="defaultSocketConfig"><bean factory-bean="socketConfigBuilder" factory-method="build" /></property>
</bean>
<bean id="connectionKeepAliveStrategy" class="com.http.client.HttpShortKeepAliveStrategy" />
<bean id="httpClientBuilder" class="org.apache.http.impl.client.HttpClientBuilder" factory-method="create">
<property name="defaultRequestConfig" ref="requestConfig" />
<property name="connectionManager" ref="poolingHttpClientConnectionManager" />
<property name="userAgent" value="Mozilla/5.0 (Windows NT 6.1; WOW64) CUSTOM-CLIENT" />
<property name="keepAliveStrategy" ref="connectionKeepAliveStrategy" />
</bean>
<bean id="httpClient" factory-bean="httpClientBuilder" factory-method="build" destroy-method="close" />
[/code]
HttpShortKeepAliveStrategy 클래스는...[code]
package com.http.client;
import org.apache.http.HeaderElement;
import org.apache.http.HeaderElementIterator;
import org.apache.http.HttpResponse;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.message.BasicHeaderElementIterator;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
/**
*
HttpShortKeepAliveStrategy (UTF-8) created : 2016. 5. 25
*
*/
public class HttpShortKeepAliveStrategy implements ConnectionKeepAliveStrategy {
/**
*
* @param response
* @param context
* @return
*/
@Override
public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
// Honor 'keep-alive' header
HeaderElementIterator it = new BasicHeaderElementIterator(
response.headerIterator(HTTP.CONN_KEEP_ALIVE));
while (it.hasNext()) {
HeaderElement he = it.nextElement();
String param = he.getName();
String value = he.getValue();
if (value != null && param.equalsIgnoreCase("timeout")) {
try {
return Long.parseLong(value) * 100;
} catch (NumberFormatException ignore) {
}
}
}
HttpHost target = (HttpHost) context.getAttribute(HttpClientContext.HTTP_TARGET_HOST);
if ("www.mydomain.com".equalsIgnoreCase(target.getHostName())) {
// Keep alive for 5 seconds only
return 5 * 1000;
} else {
// otherwise keep alive for 1 seconds
return 1 * 1000;
}
}
}
[/code]
코드로 표현하면 대강 다음과 같음.
[code]
static final CloseableHttpClient httpClient;
static {
PoolingHttpClientConnectionManager pooledManager = new PoolingHttpClientConnectionManager(20L,java.util.concurrent.TimeUnit.MILLISECONDS);
pooledManager.setMaxTotal(15);
pooledManager.setDefaultMaxPerRoute(5);
pooledManager.closeIdleConnections(20L, TimeUnit.MILLISECONDS);
pooledManager.setDefaultSocketConfig(SocketConfig.custom()
// nagle 알고리즘 적용 여부
.setTcpNoDelay(true)
// SO_KEEPALIVE를 활성화 할 경우 소켓 내부적으로 일정시간 간격으로 heartbeat을 전송하여, 비정상적인 세션 종료에 대해 감지.
// unix 계열 : /etc/sysctl.conf
// windows : \HKEY_LOCAL_MACHINE\SystemCurrentControlSet\Services\TCPIP\Parameters
.setSoKeepAlive(true)
// socket이 close 될 때 버퍼에 남아 있는 데이터를 보내는데 기다려주는 시간(blocked)
.setSoLinger(200)
// 비정상종료된 상태에서 아직 커널이 소켓의 bind정보를 유지하고 있을 때 해당 소켓을 재사용 할 수 있도록
.setSoReuseAddress(true)
//소켓이 연결된후 InputStream에서 읽을때 timeout
.setSoTimeout(10000)
.build()
);
httpClient = HttpClients.custom()
.setConnectionManager(pooledManager)
.setUserAgent("Mozilla/5.0 (Windows NT 6.1; WOW64) API-CLIENT")
.setRedirectStrategy(new DefaultRedirectStrategy() {
@Override
public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) {
boolean isRedirect = false;
try {
isRedirect = super.isRedirected(request, response, context);
} catch (ProtocolException e) {
logger.error(null, e);
}
if (!isRedirect) {
int responseCode = response.getStatusLine().getStatusCode();
if (responseCode == 301 || responseCode == 302) {
return true;
}
}
return false;
}
})
.setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
@Override
public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
// Honor 'keep-alive' header
HeaderElementIterator it = new BasicHeaderElementIterator(
response.headerIterator(HTTP.CONN_KEEP_ALIVE));
while (it.hasNext()) {
HeaderElement he = it.nextElement();
String param = he.getName();
String value = he.getValue();
if (value != null && param.equalsIgnoreCase("timeout")) {
try {
return Long.parseLong(value) * 100;
} catch (NumberFormatException ignore) {
}
}
}
HttpHost target = (HttpHost) context.getAttribute(
HttpClientContext.HTTP_TARGET_HOST);
if ("www.mydomain.com".equalsIgnoreCase(target.getHostName())) {
// Keep alive for 5 seconds only
return 5 * 1000;
} else {
// otherwise keep alive for 0.1 seconds
return 1 * 100;
}
}
})
.build();
}
[/code]