From 4a09cf9141433db24c6ab743e68371a44ffb15dd Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 18:02:02 +0200 Subject: [PATCH] feat: Graceful shutdown via stop(int) and builder shutdownTimeoutSeconds OpenApiServer.stop(int delaySeconds) delegates to HttpServer.stop, waiting up to that many seconds for in-flight exchanges to complete. The builder adds shutdownTimeoutSeconds(int) which becomes the timeout used by close() / try-with-resources (default 0 preserves prior behavior). Both reject negative values. --- README.md | 21 ++++++++ .../com/retailsvc/http/OpenApiServer.java | 47 +++++++++++++--- .../http/OpenApiServerBuilderTest.java | 54 +++++++++++++++++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a283b6a..7cd3875 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,27 @@ Built-in helpers: The original public constructors remain available for back-compat. +### Graceful shutdown + +`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the +given number of seconds for in-flight exchanges to complete before closing them. `0` stops +immediately. The same drain timeout can be wired into `close()` (and therefore +try-with-resources) via the builder: + +```java +try (var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(handlers) + .shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0 + .build()) { + // serve requests... +} // close() now waits up to 5s for in-flight exchanges +``` + +`stop(int)` and `shutdownTimeoutSeconds(int)` reject negative values with +`IllegalArgumentException`. + ## Features - OpenAPI specification support - Automatic request body parsing for JSON arrays and objects diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index a723f5b..0192075 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -32,6 +32,7 @@ public class OpenApiServer implements AutoCloseable { private static final int DEFAULT_PORT = 8080; private final HttpServer httpServer; + private final int shutdownTimeoutSeconds; /** * @param spec The parsed {@link Spec} @@ -46,7 +47,7 @@ public OpenApiServer( Map handlers, ExceptionHandler exceptionHandler) throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of()); + this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of(), 0); } /** @@ -64,7 +65,7 @@ public OpenApiServer( ExceptionHandler exceptionHandler, int port) throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of()); + this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of(), 0); } OpenApiServer( @@ -73,7 +74,8 @@ public OpenApiServer( Map handlers, ExceptionHandler exceptionHandler, int port, - Map extras) + Map extras, + int shutdownTimeoutSeconds) throws IOException { requireNonNull(spec, "Spec must not be null"); @@ -105,6 +107,8 @@ public OpenApiServer( httpServer.createContext("/", Handlers.notFoundHandler()); httpServer.start(); + this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; + LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0); } @@ -112,13 +116,26 @@ public int listenPort() { return httpServer.getAddress().getPort(); } - @Override - public void close() { + /** + * Stops the server, waiting up to {@code delaySeconds} for active exchanges to finish before + * closing them. {@code 0} stops immediately. + * + * @param delaySeconds maximum seconds to wait for in-flight exchanges; must be non-negative + */ + public void stop(int delaySeconds) { + if (delaySeconds < 0) { + throw new IllegalArgumentException("delaySeconds must be non-negative, got " + delaySeconds); + } if (httpServer != null) { - httpServer.stop(0); + httpServer.stop(delaySeconds); } } + @Override + public void close() { + stop(shutdownTimeoutSeconds); + } + public static Builder builder() { return new Builder(); } @@ -131,6 +148,7 @@ public static final class Builder { private Map handlers; private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; + private int shutdownTimeoutSeconds = 0; private final LinkedHashMap extras = new LinkedHashMap<>(); private Builder() {} @@ -160,6 +178,20 @@ public Builder port(int port) { return this; } + /** + * Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default) + * stops immediately; positive values wait up to that many seconds for in-flight exchanges to + * finish. + */ + public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) { + if (shutdownTimeoutSeconds < 0) { + throw new IllegalArgumentException( + "shutdownTimeoutSeconds must be non-negative, got " + shutdownTimeoutSeconds); + } + this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; + return this; + } + public Builder addHandler(String path, HttpHandler handler) { requireNonNull(path, "path must not be null"); requireNonNull(handler, "handler must not be null"); @@ -181,7 +213,8 @@ public OpenApiServer build() throws IOException { "extra handler path " + path + " conflicts with spec basePath " + basePath); } } - return new OpenApiServer(spec, jsonMapper, handlers, exceptionHandler, port, extras); + return new OpenApiServer( + spec, jsonMapper, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); } } } diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index 379e8a9..c9a2f3b 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -62,6 +62,60 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { .hasMessageContaining("/api"); } + @Test + void rejectsNegativeShutdownTimeout() { + OpenApiServer.Builder b = OpenApiServer.builder(); + + assertThatThrownBy(() -> b.shutdownTimeoutSeconds(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + } + + @Test + void buildsWithShutdownTimeout() { + assertDoesNotThrow( + () -> { + try (var _ = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .shutdownTimeoutSeconds(2) + .build()) { + // close on exit drains for up to 2s (no in-flight exchanges, so returns immediately) + } + }); + } + + @Test + void stopRejectsNegativeDelay() throws Exception { + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .build()) { + + assertThatThrownBy(() -> s.stop(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + } + } + + @Test + void stopWithZeroSucceeds() throws Exception { + var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .build(); + assertDoesNotThrow(() -> s.stop(0)); + } + @Test void rejectsNullSpec() { OpenApiServer.Builder b =