Skip to content

Commit 7447a0e

Browse files
authored
feat: Graceful shutdown via stop(int) and builder shutdownTimeoutSeconds (#52)
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.
1 parent b891e86 commit 7447a0e

3 files changed

Lines changed: 115 additions & 7 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@ Built-in helpers:
129129

130130
The original public constructors remain available for back-compat.
131131

132+
### Graceful shutdown
133+
134+
`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the
135+
given number of seconds for in-flight exchanges to complete before closing them. `0` stops
136+
immediately. The same drain timeout can be wired into `close()` (and therefore
137+
try-with-resources) via the builder:
138+
139+
```java
140+
try (var server = OpenApiServer.builder()
141+
.spec(spec)
142+
.jsonMapper(mapper)
143+
.handlers(handlers)
144+
.shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0
145+
.build()) {
146+
// serve requests...
147+
} // close() now waits up to 5s for in-flight exchanges
148+
```
149+
150+
`stop(int)` and `shutdownTimeoutSeconds(int)` reject negative values with
151+
`IllegalArgumentException`.
152+
132153
## Features
133154
- OpenAPI specification support
134155
- Automatic request body parsing for JSON arrays and objects

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public class OpenApiServer implements AutoCloseable {
3232
private static final int DEFAULT_PORT = 8080;
3333

3434
private final HttpServer httpServer;
35+
private final int shutdownTimeoutSeconds;
3536

3637
/**
3738
* @param spec The parsed {@link Spec}
@@ -46,7 +47,7 @@ public OpenApiServer(
4647
Map<String, HttpHandler> handlers,
4748
ExceptionHandler exceptionHandler)
4849
throws IOException {
49-
this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of());
50+
this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of(), 0);
5051
}
5152

5253
/**
@@ -64,7 +65,7 @@ public OpenApiServer(
6465
ExceptionHandler exceptionHandler,
6566
int port)
6667
throws IOException {
67-
this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of());
68+
this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of(), 0);
6869
}
6970

7071
OpenApiServer(
@@ -73,7 +74,8 @@ public OpenApiServer(
7374
Map<String, HttpHandler> handlers,
7475
ExceptionHandler exceptionHandler,
7576
int port,
76-
Map<String, HttpHandler> extras)
77+
Map<String, HttpHandler> extras,
78+
int shutdownTimeoutSeconds)
7779
throws IOException {
7880

7981
requireNonNull(spec, "Spec must not be null");
@@ -105,20 +107,35 @@ public OpenApiServer(
105107
httpServer.createContext("/", Handlers.notFoundHandler());
106108
httpServer.start();
107109

110+
this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;
111+
108112
LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0);
109113
}
110114

111115
public int listenPort() {
112116
return httpServer.getAddress().getPort();
113117
}
114118

115-
@Override
116-
public void close() {
119+
/**
120+
* Stops the server, waiting up to {@code delaySeconds} for active exchanges to finish before
121+
* closing them. {@code 0} stops immediately.
122+
*
123+
* @param delaySeconds maximum seconds to wait for in-flight exchanges; must be non-negative
124+
*/
125+
public void stop(int delaySeconds) {
126+
if (delaySeconds < 0) {
127+
throw new IllegalArgumentException("delaySeconds must be non-negative, got " + delaySeconds);
128+
}
117129
if (httpServer != null) {
118-
httpServer.stop(0);
130+
httpServer.stop(delaySeconds);
119131
}
120132
}
121133

134+
@Override
135+
public void close() {
136+
stop(shutdownTimeoutSeconds);
137+
}
138+
122139
public static Builder builder() {
123140
return new Builder();
124141
}
@@ -131,6 +148,7 @@ public static final class Builder {
131148
private Map<String, HttpHandler> handlers;
132149
private ExceptionHandler exceptionHandler;
133150
private int port = DEFAULT_PORT;
151+
private int shutdownTimeoutSeconds = 0;
134152
private final LinkedHashMap<String, HttpHandler> extras = new LinkedHashMap<>();
135153

136154
private Builder() {}
@@ -160,6 +178,20 @@ public Builder port(int port) {
160178
return this;
161179
}
162180

181+
/**
182+
* Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default)
183+
* stops immediately; positive values wait up to that many seconds for in-flight exchanges to
184+
* finish.
185+
*/
186+
public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) {
187+
if (shutdownTimeoutSeconds < 0) {
188+
throw new IllegalArgumentException(
189+
"shutdownTimeoutSeconds must be non-negative, got " + shutdownTimeoutSeconds);
190+
}
191+
this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;
192+
return this;
193+
}
194+
163195
public Builder addHandler(String path, HttpHandler handler) {
164196
requireNonNull(path, "path must not be null");
165197
requireNonNull(handler, "handler must not be null");
@@ -181,7 +213,8 @@ public OpenApiServer build() throws IOException {
181213
"extra handler path " + path + " conflicts with spec basePath " + basePath);
182214
}
183215
}
184-
return new OpenApiServer(spec, jsonMapper, handlers, exceptionHandler, port, extras);
216+
return new OpenApiServer(
217+
spec, jsonMapper, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds);
185218
}
186219
}
187220
}

src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,60 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() {
6262
.hasMessageContaining("/api");
6363
}
6464

65+
@Test
66+
void rejectsNegativeShutdownTimeout() {
67+
OpenApiServer.Builder b = OpenApiServer.builder();
68+
69+
assertThatThrownBy(() -> b.shutdownTimeoutSeconds(-1))
70+
.isInstanceOf(IllegalArgumentException.class)
71+
.hasMessageContaining("-1");
72+
}
73+
74+
@Test
75+
void buildsWithShutdownTimeout() {
76+
assertDoesNotThrow(
77+
() -> {
78+
try (var _ =
79+
OpenApiServer.builder()
80+
.spec(spec)
81+
.jsonMapper(jsonMapper)
82+
.handlers(emptyMap())
83+
.port(0)
84+
.shutdownTimeoutSeconds(2)
85+
.build()) {
86+
// close on exit drains for up to 2s (no in-flight exchanges, so returns immediately)
87+
}
88+
});
89+
}
90+
91+
@Test
92+
void stopRejectsNegativeDelay() throws Exception {
93+
try (var s =
94+
OpenApiServer.builder()
95+
.spec(spec)
96+
.jsonMapper(jsonMapper)
97+
.handlers(emptyMap())
98+
.port(0)
99+
.build()) {
100+
101+
assertThatThrownBy(() -> s.stop(-1))
102+
.isInstanceOf(IllegalArgumentException.class)
103+
.hasMessageContaining("-1");
104+
}
105+
}
106+
107+
@Test
108+
void stopWithZeroSucceeds() throws Exception {
109+
var s =
110+
OpenApiServer.builder()
111+
.spec(spec)
112+
.jsonMapper(jsonMapper)
113+
.handlers(emptyMap())
114+
.port(0)
115+
.build();
116+
assertDoesNotThrow(() -> s.stop(0));
117+
}
118+
65119
@Test
66120
void rejectsNullSpec() {
67121
OpenApiServer.Builder b =

0 commit comments

Comments
 (0)