diff --git a/src/main/java/org/juv25d/filter/TimeoutFilter.java b/src/main/java/org/juv25d/filter/TimeoutFilter.java new file mode 100644 index 00000000..a8542416 --- /dev/null +++ b/src/main/java/org/juv25d/filter/TimeoutFilter.java @@ -0,0 +1,69 @@ +package org.juv25d.filter; + +import org.juv25d.filter.annotation.Global; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.logging.ServerLogging; + +import java.io.IOException; +import java.util.concurrent.*; +import java.util.logging.Logger; + +@Global(order = 1) +public class TimeoutFilter implements Filter { + + private static final long TIMEOUT_MS = 2000; + + private static final ExecutorService executor = + Executors.newCachedThreadPool(); + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + executor.shutdownNow(); + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + + private static final Logger logger = ServerLogging.getLogger(); + + @Override + public void doFilter(HttpRequest req, + HttpResponse res, + FilterChain chain) throws IOException { + + logger.info("TimeoutFilter START for " + req.path()); + + Future future = executor.submit(() -> { + try { + chain.doFilter(req, res); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + try { + future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + logger.info("TimeoutFilter COMPLETED normally for " + req.path()); + + } catch (TimeoutException e) { + + logger.warning("Timeout triggered for " + req.path()); + + future.cancel(true); + + res.setStatusCode(504); + res.setStatusText("Gateway Timeout"); + res.setBody("504 - Gateway Timeout".getBytes()); + + } catch (Exception e) { + + future.cancel(true); + throw new RuntimeException(e); + } + } +} + + diff --git a/src/main/java/org/juv25d/plugin/SlowPlugin.java b/src/main/java/org/juv25d/plugin/SlowPlugin.java new file mode 100644 index 00000000..e0be45ea --- /dev/null +++ b/src/main/java/org/juv25d/plugin/SlowPlugin.java @@ -0,0 +1,23 @@ +package org.juv25d.plugin; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public class SlowPlugin implements Plugin { + + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + return; + } + + res.setStatusCode(200); + res.setStatusText("OK"); + res.setBody("Slow response finished".getBytes()); + } +} diff --git a/src/main/java/org/juv25d/router/RouterConfig.java b/src/main/java/org/juv25d/router/RouterConfig.java index 805529e8..e425a6b1 100644 --- a/src/main/java/org/juv25d/router/RouterConfig.java +++ b/src/main/java/org/juv25d/router/RouterConfig.java @@ -8,7 +8,7 @@ import org.juv25d.proxy.ProxyPlugin; import org.juv25d.proxy.ProxyRoute; import org.juv25d.util.ConfigLoader; - +import org.juv25d.plugin.SlowPlugin; public class RouterConfig { @Inject @@ -21,6 +21,7 @@ public RouterConfig(SimpleRouter router) { router.registerPlugin("/metric", new MetricPlugin()); router.registerPlugin("/health", new HealthCheckPlugin()); + router.registerPlugin("/slow", new SlowPlugin()); router.registerPlugin("/", new StaticFilesPlugin()); router.registerPlugin("/*", new StaticFilesPlugin()); router.registerPlugin("/notfound", new NotFoundPlugin()); diff --git a/src/test/java/org/juv25d/filter/TimeoutFilterTest.java b/src/test/java/org/juv25d/filter/TimeoutFilterTest.java new file mode 100644 index 00000000..a05e7cb6 --- /dev/null +++ b/src/test/java/org/juv25d/filter/TimeoutFilterTest.java @@ -0,0 +1,73 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TimeoutFilterTest { + + @Mock HttpRequest req; + @Mock FilterChain chain; + + @Test + void fastRequest_keepsDefault200() throws IOException { + when(req.path()).thenReturn("/fast"); + HttpResponse res = new HttpResponse(); + TimeoutFilter filter = new TimeoutFilter(); + + doNothing().when(chain).doFilter(req, res); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + assertThat(res.statusCode()).isEqualTo(200); + } + + @Test + @Timeout(value = 4, unit = TimeUnit.SECONDS) + void slowRequest_sets504() throws IOException { + when(req.path()).thenReturn("/slow"); + HttpResponse res = new HttpResponse(); + TimeoutFilter filter = new TimeoutFilter(); + + doAnswer(inv -> { + Thread.sleep(3_000); // > 2000ms => timeout + return null; + }).when(chain).doFilter(req, res); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + assertThat(res.statusCode()).isEqualTo(504); + assertThat(res.statusText()).isEqualTo("Gateway Timeout"); + assertThat(new String(res.body(), StandardCharsets.UTF_8)) + .isEqualTo("504 - Gateway Timeout"); + } + + @Test + void downstreamIOException_throwsRuntimeException() throws IOException { + when(req.path()).thenReturn("/boom"); + HttpResponse res = new HttpResponse(); + TimeoutFilter filter = new TimeoutFilter(); + + doThrow(new IOException("fail")).when(chain).doFilter(req, res); + + assertThatThrownBy(() -> filter.doFilter(req, res, chain)) + .isInstanceOf(RuntimeException.class); + + verify(chain).doFilter(req, res); + } +}