package co.codewizards.cloudstore.ls.rest.client; import static co.codewizards.cloudstore.core.util.Util.*; import java.util.LinkedList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import javax.ws.rs.WebApplicationException; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Invocation; import javax.ws.rs.client.ResponseProcessingException; import javax.ws.rs.core.Response; import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import co.codewizards.cloudstore.core.Uid; import co.codewizards.cloudstore.core.concurrent.DeferredCompletionException; import co.codewizards.cloudstore.core.config.Config; import co.codewizards.cloudstore.core.config.ConfigImpl; import co.codewizards.cloudstore.core.dto.Error; import co.codewizards.cloudstore.core.dto.RemoteException; import co.codewizards.cloudstore.core.dto.RemoteExceptionUtil; import co.codewizards.cloudstore.core.util.AssertUtil; import co.codewizards.cloudstore.ls.core.LocalServerPropertiesManager; import co.codewizards.cloudstore.ls.core.provider.JavaNativeMessageBodyReader; import co.codewizards.cloudstore.ls.core.provider.JavaNativeMessageBodyWriter; import co.codewizards.cloudstore.ls.rest.client.request.Request; /** * Client for executing REST requests. * <p> * An instance of this class is used to send data to, query data from or execute logic on the server. * <p> * If a series of multiple requests is to be sent to the server, it is recommended to keep an instance of * this class (because it caches resources) and invoke multiple requests with it. * <p> * This class is thread-safe. * @author Marco หงุ่ยตระกูล-Schulze - marco at codewizards dot co */ public class LocalServerRestClient { private static final Logger logger = LoggerFactory.getLogger(LocalServerRestClient.class); private static final int DEFAULT_SOCKET_CONNECT_TIMEOUT = 30 * 1000; // 30 seconds should really be sufficient to connect to localhost private static final int DEFAULT_SOCKET_READ_TIMEOUT = 3 * 60 * 1000; // 3 minutes is more than enough, because we have DelayedMethodInvocationResponse /** * The {@code key} for the connection timeout used with {@link Config#getPropertyAsInt(String, int)}. * <p> * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}. */ public static final String CONFIG_KEY_SOCKET_CONNECT_TIMEOUT = "localServer.socket.connectTimeout"; //$NON-NLS-1$ /** * The {@code key} for the read timeout used with {@link Config#getPropertyAsInt(String, int)}. * <p> * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}. */ public static final String CONFIG_KEY_SOCKET_READ_TIMEOUT = "localServer.socket.readTimeout"; //$NON-NLS-1$ private Integer socketConnectTimeout; private Integer socketReadTimeout; private String baseUrl; private final LinkedList<Client> clientCache = new LinkedList<Client>(); private CredentialsProvider credentialsProvider; private static final class Holder { public static final LocalServerRestClient instance = new LocalServerRestClient(); } public static LocalServerRestClient getInstance() { return Holder.instance; } public synchronized Integer getSocketConnectTimeout() { if (socketConnectTimeout == null) socketConnectTimeout = ConfigImpl.getInstance().getPropertyAsPositiveOrZeroInt( CONFIG_KEY_SOCKET_CONNECT_TIMEOUT, DEFAULT_SOCKET_CONNECT_TIMEOUT); return socketConnectTimeout; } public synchronized void setSocketConnectTimeout(Integer socketConnectTimeout) { if (socketConnectTimeout != null && socketConnectTimeout < 0) socketConnectTimeout = null; this.socketConnectTimeout = socketConnectTimeout; } public synchronized Integer getSocketReadTimeout() { if (socketReadTimeout == null) socketReadTimeout = ConfigImpl.getInstance().getPropertyAsPositiveOrZeroInt( CONFIG_KEY_SOCKET_READ_TIMEOUT, DEFAULT_SOCKET_READ_TIMEOUT); return socketReadTimeout; } public synchronized void setSocketReadTimeout(Integer socketReadTimeout) { if (socketReadTimeout != null && socketReadTimeout < 0) socketReadTimeout = null; this.socketReadTimeout = socketReadTimeout; } /** * Get the server's base-URL. * <p> * This base-URL is the base of the <code>LocalServerRest</code> application. Hence all URLs * beneath this base-URL are processed by the <code>LocalServerRest</code> application. * @return the base-URL. This URL always ends with "/". */ public synchronized String getBaseUrl() { if (baseUrl == null) baseUrl = LocalServerPropertiesManager.getInstance().getBaseUrl(); return baseUrl; } /** * Create a new client. */ protected LocalServerRestClient() { // The clientId is used for memory management in the server: if a client is closed or disappears, i.e. doesn't // send keep-alives regularly, anymore, the server removes all objectRef-references kept for this client in its ObjectManager. final String clientId = new Uid().toString(); setCredentialsProvider(new CredentialsProvider() { @Override public String getUserName() { return clientId; } @Override public String getPassword() { return LocalServerPropertiesManager.getInstance().getPassword(); } }); } // private static String appendFinalSlashIfNeeded(final String url) { // return url.endsWith("/") ? url : url + "/"; // } public <R> R execute(final Request<R> request) { AssertUtil.assertNotNull(request, "request"); RuntimeException firstException = null; int retryCounter = 0; // *re*-try: first (normal) invocation is 0, first re-try is 1 final int retryMax = 1; // *re*-try: 1 retries means 2 invocations in total while (true) { acquireClient(); try { final long start = System.currentTimeMillis(); if (logger.isDebugEnabled()) logger.debug("execute: starting try {} of {}", retryCounter + 1, retryMax + 1); try { request.setLocalServerRestClient(this); final R result = request.execute(); if (logger.isDebugEnabled()) logger.debug("execute: invocation took {} ms", System.currentTimeMillis() - start); if (result == null && !request.isResultNullable()) throw new IllegalStateException("result == null, but request.resultNullable == false!"); return result; } catch (final RuntimeException x) { if (firstException == null) firstException = x; final String oldBaseUrl = getBaseUrl(); baseUrl = null; if (!oldBaseUrl.equals(getBaseUrl())) { retryCounter = 0; // reset to make sure we really try again with the new URL clearClientCache(); } markClientBroken(); // make sure we do not reuse this client if (++retryCounter > retryMax || !retryExecuteAfterException(x)) { logger.warn("execute: invocation failed (will NOT retry): " + x, x); handleAndRethrowException(firstException); // TODO maybe we should make a MultiCauseException?! throw firstException; } logger.warn("execute: invocation failed (will retry): " + x, x); // Wait a bit before retrying (increasingly longer). try { Thread.sleep(retryCounter * 1000L); } catch (Exception y) { doNothing(); } } } finally { releaseClient(); request.setLocalServerRestClient(null); } } } private synchronized void clearClientCache() { clientCache.clear(); } private boolean retryExecuteAfterException(final Exception x) { // final Class<?>[] exceptionClassesCausingRetry = new Class<?>[] { // SSLException.class, // SocketException.class // }; // for (final Class<?> exceptionClass : exceptionClassesCausingRetry) { // @SuppressWarnings("unchecked") // final Class<? extends Throwable> xc = (Class<? extends Throwable>) exceptionClass; // if (ExceptionUtil.getCause(x, xc) != null) { // logger.warn( // String.format("retryExecuteAfterException: Encountered %s and will retry.", xc.getSimpleName()), // x); // return true; // } // } // return false; return true; } public Invocation.Builder assignCredentials(final Invocation.Builder builder) { final CredentialsProvider credentialsProvider = getCredentialsProviderOrFail(); builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_USERNAME, credentialsProvider.getUserName()); builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_PASSWORD, credentialsProvider.getPassword()); return builder; } private final ThreadLocal<ClientRef> clientThreadLocal = new ThreadLocal<ClientRef>(); private static class ClientRef { public final Client client; public int refCount = 1; public boolean broken; public ClientRef(final Client client) { this.client = AssertUtil.assertNotNull(client, "client"); } } /** * Acquire a {@link Client} and bind it to the current thread. * <p> * <b>Important: You must {@linkplain #releaseClient() release} the client!</b> Use a try/finally block! * @see #releaseClient() * @see #getClientOrFail() */ private synchronized void acquireClient() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef != null) { ++clientRef.refCount; return; } Client client = clientCache.poll(); if (client == null) { final ClientConfig clientConfig = new ClientConfig(CloudStoreJaxbContextResolver.class); clientConfig.property(ClientProperties.CONNECT_TIMEOUT, getSocketConnectTimeout()); // must be a java.lang.Integer clientConfig.property(ClientProperties.READ_TIMEOUT, getSocketReadTimeout()); // must be a java.lang.Integer final ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(clientConfig); clientBuilder.register(JavaNativeMessageBodyReader.class); clientBuilder.register(JavaNativeMessageBodyWriter.class); for (final Object restComponent : restComponents) { if (restComponent instanceof Class<?>) clientBuilder.register((Class<?>) restComponent); else clientBuilder.register(restComponent); } client = clientBuilder.build(); // An authentication is always required. Otherwise Jersey throws an exception. // Hence, we set it to "anonymous" here and set it to the real values for those // requests really requiring it. final HttpAuthenticationFeature feature = HttpAuthenticationFeature.basic("anonymous", ""); client.register(feature); // configFrozen = true; } clientThreadLocal.set(new ClientRef(client)); } private final List<Object> restComponents = new CopyOnWriteArrayList<Object>(); public void registerRestComponent(Object restComponent) { restComponents.add(restComponent); } /** * Get the {@link Client} which was previously {@linkplain #acquireClient() acquired} (and not yet * {@linkplain #releaseClient() released}) on the same thread. * @return the {@link Client}. Never <code>null</code>. * @throws IllegalStateException if there is no {@link Client} bound to the current thread. * @see #acquireClient() */ public Client getClientOrFail() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() already called)!"); return clientRef.client; } /** * Release a {@link Client} which was previously {@linkplain #acquireClient() acquired}. * @see #acquireClient() */ private synchronized void releaseClient() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); if (--clientRef.refCount == 0) { clientThreadLocal.remove(); if (!clientRef.broken) clientCache.add(clientRef.client); } } private void markClientBroken() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); clientRef.broken = true; } public void handleAndRethrowException(final RuntimeException x) { Response response = null; if (x instanceof WebApplicationException) response = ((WebApplicationException)x).getResponse(); else if (x instanceof ResponseProcessingException) response = ((ResponseProcessingException)x).getResponse(); if (response == null) throw x; Error error = null; try { response.bufferEntity(); if (response.hasEntity()) error = response.readEntity(Error.class); if (error != null && DeferredCompletionException.class.getName().equals(error.getClassName())) logger.debug("handleException: " + x, x); else logger.error("handleException: " + x, x); } catch (final Exception y) { logger.error("handleException: " + x, x); logger.error("handleException: " + y, y); } if (error != null) { RemoteExceptionUtil.throwOriginalExceptionIfPossible(error); throw new RemoteException(error); } throw x; } public CredentialsProvider getCredentialsProvider() { return credentialsProvider; } private CredentialsProvider getCredentialsProviderOrFail() { final CredentialsProvider credentialsProvider = getCredentialsProvider(); if (credentialsProvider == null) throw new IllegalStateException("credentialsProvider == null"); return credentialsProvider; } public void setCredentialsProvider(final CredentialsProvider credentialsProvider) { this.credentialsProvider = credentialsProvider; } }