From 9a050461a8297b01292514f27521e13c0d6706d8 Mon Sep 17 00:00:00 2001 From: James Agnew Date: Tue, 15 Oct 2019 09:34:48 -0400 Subject: [PATCH] Make cache timeout configurable --- .../ca/uhn/fhir/rest/annotation/Metadata.java | 4 +- .../provider/r4/ResourceProviderR4Test.java | 6 +- .../method/ConformanceMethodBinding.java | 64 ++++++--- .../method/ConformanceMethodBindingTest.java | 127 ++++++++++++++---- .../server/MetadataConformanceDstu3Test.java | 123 ++++++++--------- src/changes/changes.xml | 4 + 6 files changed, 214 insertions(+), 114 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/annotation/Metadata.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/annotation/Metadata.java index a084f608ee8..07f2f169174 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/annotation/Metadata.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/annotation/Metadata.java @@ -36,5 +36,7 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(value=ElementType.METHOD) public @interface Metadata { - // nothing for now + + long cacheMillis() default 60 * 1000L; + } diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4Test.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4Test.java index b2b6fffd47f..df66a24090e 100644 --- a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4Test.java +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4Test.java @@ -2669,15 +2669,11 @@ public class ResourceProviderR4Test extends BaseResourceProviderR4Test { @Test public void testMetadata() throws Exception { HttpGet get = new HttpGet(ourServerBase + "/metadata"); - CloseableHttpResponse response = ourHttpClient.execute(get); - try { + try (CloseableHttpResponse response = ourHttpClient.execute(get)) { String resp = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); ourLog.info(resp); assertEquals(200, response.getStatusLine().getStatusCode()); assertThat(resp, stringContainsInOrder("THIS IS THE DESC")); - } finally { - response.getEntity().getContent().close(); - response.close(); } } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBinding.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBinding.java index 4004998b129..9a445aaaab4 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBinding.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBinding.java @@ -20,22 +20,14 @@ package ca.uhn.fhir.rest.server.method; * #L% */ -import java.lang.reflect.Method; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -import ca.uhn.fhir.interceptor.api.HookParams; -import ca.uhn.fhir.interceptor.api.Pointcut; -import ca.uhn.fhir.rest.api.CacheControlDirective; -import ca.uhn.fhir.rest.api.Constants; -import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor; -import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; -import org.hl7.fhir.instance.model.api.IBaseConformance; -import org.hl7.fhir.instance.model.api.IBaseResource; - import ca.uhn.fhir.context.ConfigurationException; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.api.HookParams; +import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.model.valueset.BundleTypeEnum; +import ca.uhn.fhir.rest.annotation.Metadata; +import ca.uhn.fhir.rest.api.CacheControlDirective; +import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.RequestTypeEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.IBundleProvider; @@ -44,12 +36,17 @@ import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.server.SimpleBundleProvider; import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; +import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor; +import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; +import org.hl7.fhir.instance.model.api.IBaseConformance; +import org.hl7.fhir.instance.model.api.IBaseResource; import javax.annotation.Nonnull; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; public class ConformanceMethodBinding extends BaseResourceReturningMethodBinding { - private static final long CACHE_MILLIS = 60 * 1000; - /* * Note: This caching mechanism should probably be configurable and maybe * even applicable to other bindings. It's particularly important for this @@ -57,7 +54,7 @@ public class ConformanceMethodBinding extends BaseResourceReturningMethodBinding */ private final AtomicReference myCachedResponse = new AtomicReference<>(); private final AtomicLong myCachedResponseExpires = new AtomicLong(0L); - + private long myCacheMillis = 60 * 1000; ConformanceMethodBinding(Method theMethod, FhirContext theContext, Object theProvider) { super(theMethod.getReturnType(), theMethod, theContext, theProvider); @@ -68,6 +65,35 @@ public class ConformanceMethodBinding extends BaseResourceReturningMethodBinding throw new ConfigurationException("Conformance resource provider method '" + theMethod.getName() + "' should return a Conformance resource class, returns: " + theMethod.getReturnType()); } + Metadata metadata = theMethod.getAnnotation(Metadata.class); + if (metadata != null) { + setCacheMillis(metadata.cacheMillis()); + } + + } + + /** + * Returns the number of milliseconds to cache the generated CapabilityStatement for. Default is one minute, and can be + * set to 0 to never cache. + * + * @see #setCacheMillis(long) + * @see Metadata#cacheMillis() + * @since 4.1.0 + */ + private long getCacheMillis() { + return myCacheMillis; + } + + /** + * Returns the number of milliseconds to cache the generated CapabilityStatement for. Default is one minute, and can be + * set to 0 to never cache. + * + * @see #getCacheMillis() + * @see Metadata#cacheMillis() + * @since 4.1.0 + */ + private void setCacheMillis(long theCacheMillis) { + myCacheMillis = theCacheMillis; } @Override @@ -116,8 +142,10 @@ public class ConformanceMethodBinding extends BaseResourceReturningMethodBinding if (conf == null) { conf = (IBaseResource) invokeServerMethod(theServer, theRequest, theMethodParams); - myCachedResponse.set(conf); - myCachedResponseExpires.set(System.currentTimeMillis() + CACHE_MILLIS); + if (myCacheMillis > 0) { + myCachedResponse.set(conf); + myCachedResponseExpires.set(System.currentTimeMillis() + getCacheMillis()); + } } return new SimpleBundleProvider(conf); diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBindingTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBindingTest.java index 6ef2ea1de5c..f686ff0864f 100644 --- a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBindingTest.java +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/method/ConformanceMethodBindingTest.java @@ -1,29 +1,18 @@ package ca.uhn.fhir.rest.server.method; import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.context.RuntimeResourceDefinition; -import ca.uhn.fhir.rest.annotation.OptionalParam; -import ca.uhn.fhir.rest.annotation.RequiredParam; -import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.annotation.Metadata; import ca.uhn.fhir.rest.api.Constants; -import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.IRestfulServer; import ca.uhn.fhir.rest.api.server.RequestDetails; -import ca.uhn.fhir.rest.server.IResourceProvider; -import ca.uhn.fhir.rest.server.util.BaseServerCapabilityStatementProvider; import com.google.common.collect.Lists; import org.hl7.fhir.instance.model.api.IBaseConformance; -import org.hl7.fhir.instance.model.api.IBaseMetaType; -import org.hl7.fhir.instance.model.api.IBaseResource; -import org.hl7.fhir.instance.model.api.IIdType; import org.junit.Before; import org.junit.Test; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; -import java.util.Arrays; -import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -31,40 +20,130 @@ public class ConformanceMethodBindingTest { private FhirContext fhirContext; private ConformanceMethodBinding conformanceMethodBinding; - private TestResourceProvider dummy; @Before - public void setUp() throws NoSuchMethodException { + public void setUp() { fhirContext = mock(FhirContext.class); - dummy = spy(new TestResourceProvider()); - Method method = dummy.getClass().getDeclaredMethod("getServerConformance", HttpServletRequest.class, RequestDetails.class); - conformanceMethodBinding = new ConformanceMethodBinding(method, fhirContext, dummy); + } + private T init(T theCapabilityStatementProvider) throws NoSuchMethodException { + T provider = spy(theCapabilityStatementProvider); + Method method = provider.getClass().getDeclaredMethod("getServerConformance", HttpServletRequest.class, RequestDetails.class); + conformanceMethodBinding = new ConformanceMethodBinding(method, fhirContext, provider); + return provider; } @Test - public void invokeServerCached() { + public void invokeServerCached() throws NoSuchMethodException { + TestResourceProvider provider = init(new TestResourceProvider()); + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); - verify(dummy, times(1)).getServerConformance(any(), any()); + verify(provider, times(1)).getServerConformance(any(), any()); conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); - verify(dummy, times(1)).getServerConformance(any(), any()); + verify(provider, times(1)).getServerConformance(any(), any()); } @Test - public void invokeServerNotCached_ClientControlled() { + public void invokeServerCacheExpires() throws NoSuchMethodException { + TestResourceProviderSmallCache provider = init(new TestResourceProviderSmallCache()); + + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(1)).getServerConformance(any(), any()); + + sleepAtLeast(20); + + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(2)).getServerConformance(any(), any()); + } + + @Test + public void invokeServerCacheDisabled() throws NoSuchMethodException { + TestResourceProviderNoCache provider = init(new TestResourceProviderNoCache()); + + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(1)).getServerConformance(any(), any()); + + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(2)).getServerConformance(any(), any()); + } + + @Test + public void invokeServerCacheDisabledInSuperclass() throws NoSuchMethodException { + TestResourceProviderNoCache2 provider = init(new TestResourceProviderNoCache2()); + + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(1)).getServerConformance(any(), any()); + + // We currently don't scan the annotation on the superclass...Perhaps we should + conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), mock(RequestDetails.class, RETURNS_DEEP_STUBS), new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); + verify(provider, times(1)).getServerConformance(any(), any()); + } + + @Test + public void invokeServerNotCached_ClientControlled() throws NoSuchMethodException { + TestResourceProvider provider = init(new TestResourceProvider()); + RequestDetails requestDetails = mock(RequestDetails.class, RETURNS_DEEP_STUBS); when(requestDetails.getHeaders(Constants.HEADER_CACHE_CONTROL)).thenReturn(Lists.newArrayList(Constants.CACHE_CONTROL_NO_CACHE)); conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), requestDetails, new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); - verify(dummy, times(1)).getServerConformance(any(), any()); + verify(provider, times(1)).getServerConformance(any(), any()); conformanceMethodBinding.invokeServer(mock(IRestfulServer.class, RETURNS_DEEP_STUBS), requestDetails, new Object[]{mock(HttpServletRequest.class), mock(RequestDetails.class)}); - verify(dummy, times(2)).getServerConformance(any(), any()); + verify(provider, times(2)).getServerConformance(any(), any()); } - class TestResourceProvider { + @SuppressWarnings("unused") + static class TestResourceProvider { + @Metadata public IBaseConformance getServerConformance(HttpServletRequest theRequest, RequestDetails theRequestDetails) { return mock(IBaseConformance.class, RETURNS_DEEP_STUBS); } } + + @SuppressWarnings("unused") + static class TestResourceProviderSmallCache { + + @Metadata(cacheMillis = 10) + public IBaseConformance getServerConformance(HttpServletRequest theRequest, RequestDetails theRequestDetails) { + + return mock(IBaseConformance.class, RETURNS_DEEP_STUBS); + } + } + + @SuppressWarnings("unused") + static class TestResourceProviderNoCache { + + @Metadata(cacheMillis = 0) + public IBaseConformance getServerConformance(HttpServletRequest theRequest, RequestDetails theRequestDetails) { + + return mock(IBaseConformance.class, RETURNS_DEEP_STUBS); + } + + } + + @SuppressWarnings("unused") + static class TestResourceProviderNoCache2 extends TestResourceProviderNoCache { + + // No @Metadata + @Override + public IBaseConformance getServerConformance(HttpServletRequest theRequest, RequestDetails theRequestDetails) { + return mock(IBaseConformance.class, RETURNS_DEEP_STUBS); + } + + } + + private static void sleepAtLeast(long theMillis) { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() <= start + theMillis) { + try { + long timeSinceStarted = System.currentTimeMillis() - start; + long timeToSleep = Math.max(0, theMillis - timeSinceStarted); + Thread.sleep(timeToSleep); + } catch (InterruptedException theE) { + // ignore + } + } + } + } diff --git a/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/rest/server/MetadataConformanceDstu3Test.java b/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/rest/server/MetadataConformanceDstu3Test.java index f2309d2cb1a..2f41503a641 100644 --- a/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/rest/server/MetadataConformanceDstu3Test.java +++ b/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/rest/server/MetadataConformanceDstu3Test.java @@ -1,21 +1,19 @@ package ca.uhn.fhir.rest.server; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.stringContainsInOrder; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThat; - -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.TimeUnit; - +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.annotation.OptionalParam; +import ca.uhn.fhir.rest.annotation.ResourceParam; +import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.annotation.Validate; import ca.uhn.fhir.rest.api.EncodingEnum; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.test.utilities.JettyUtil; +import ca.uhn.fhir.util.TestUtil; +import ca.uhn.fhir.util.VersionUtil; import org.apache.commons.io.IOUtils; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.methods.*; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; @@ -28,28 +26,23 @@ import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.context.FhirVersionEnum; -import ca.uhn.fhir.rest.annotation.OptionalParam; -import ca.uhn.fhir.rest.annotation.ResourceParam; -import ca.uhn.fhir.rest.annotation.Search; -import ca.uhn.fhir.rest.annotation.Validate; -import ca.uhn.fhir.rest.api.MethodOutcome; -import ca.uhn.fhir.rest.param.StringParam; -import ca.uhn.fhir.test.utilities.JettyUtil; -import ca.uhn.fhir.util.TestUtil; -import ca.uhn.fhir.util.VersionUtil; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; public class MetadataConformanceDstu3Test { + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(MetadataConformanceDstu3Test.class); private static CloseableHttpClient ourClient; private static FhirContext ourCtx = FhirContext.forDstu3(); private static int ourPort; private static Server ourServer; private static RestfulServer ourServlet; - private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(MetadataConformanceDstu3Test.class); - @Test public void testSummary() throws Exception { String output; @@ -61,7 +54,7 @@ public class MetadataConformanceDstu3Test { output = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8); assertEquals(200, status.getStatusLine().getStatusCode()); ourLog.info(output); - assertThat(output, containsString("", "SUBSETTED", "")); assertThat(output, not(stringContainsInOrder("searchParam"))); } finally { @@ -75,7 +68,7 @@ public class MetadataConformanceDstu3Test { output = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8); assertEquals(200, status.getStatusLine().getStatusCode()); ourLog.info(output); - assertThat(output, containsString("", "SUBSETTED", ""))); assertThat(output, stringContainsInOrder("searchParam")); } finally { @@ -104,38 +97,55 @@ public class MetadataConformanceDstu3Test { public void testHttpMethods() throws Exception { String output; - HttpRequestBase httpPost = new HttpGet("http://localhost:" + ourPort + "/metadata"); - CloseableHttpResponse status = ourClient.execute(httpPost); - try { + HttpRequestBase httpOperation = new HttpGet("http://localhost:" + ourPort + "/metadata"); + try (CloseableHttpResponse status = ourClient.execute(httpOperation)) { output = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8); assertEquals(200, status.getStatusLine().getStatusCode()); - assertThat(output, containsString("", output); - } finally { - IOUtils.closeQuietly(status.getEntity().getContent()); } /* * There is no @read on the RP below, so this should fail. Otherwise it * would be interpreted as a read on ID "metadata" */ - try { - httpPost = new HttpGet("http://localhost:" + ourPort + "/Patient/metadata"); - status = ourClient.execute(httpPost); - output = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8); + httpOperation = new HttpGet("http://localhost:" + ourPort + "/Patient/metadata"); + try (CloseableHttpResponse status = ourClient.execute(httpOperation)) { assertEquals(400, status.getStatusLine().getStatusCode()); - } finally { - IOUtils.closeQuietly(status.getEntity().getContent()); + } + } + + @SuppressWarnings("unused") + public static class DummyPatientResourceProvider implements IResourceProvider { + + @Override + public Class getResourceType() { + return Patient.class; + } + + @Search + public List search(@OptionalParam(name = "foo") StringParam theFoo) { + throw new UnsupportedOperationException(); + } + + @Validate() + public MethodOutcome validate(@ResourceParam Patient theResource) { + return new MethodOutcome(); } } @@ -160,7 +170,7 @@ public class MetadataConformanceDstu3Test { proxyHandler.addServletWithMapping(servletHolder, "/*"); ourServer.setHandler(proxyHandler); JettyUtil.startServer(ourServer); - ourPort = JettyUtil.getPortForStartedServer(ourServer); + ourPort = JettyUtil.getPortForStartedServer(ourServer); PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS); HttpClientBuilder builder = HttpClientBuilder.create(); @@ -169,23 +179,4 @@ public class MetadataConformanceDstu3Test { } - @SuppressWarnings("unused") - public static class DummyPatientResourceProvider implements IResourceProvider { - - @Override - public Class getResourceType() { - return Patient.class; - } - - @Search - public List search(@OptionalParam(name="foo") StringParam theFoo) { - throw new UnsupportedOperationException(); - } - - @Validate() - public MethodOutcome validate(@ResourceParam Patient theResource) { - return new MethodOutcome(); - } - } - } diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 93d00994826..0a928d4340e 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -363,6 +363,10 @@ The server CapabilityStatement (/metadata) endpoint now respects the Cache-Control header. Thanks to Jens Villadsen for the pull request! + + The @Metadata annotation now has an attribute that can be used to control + the cache timeout +