Skip to content

Commit dccfe49

Browse files
authored
feat: grace period for HTTP/2 GOAWAY (#4528)
1 parent 8fb19fc commit dccfe49

7 files changed

Lines changed: 167 additions & 24 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# not for user extension
2+
ProblemFilters.exclude[ReversedMissingMethodProblem]("akka.http.scaladsl.settings.Http2ClientSettings.goawayGracePeriod")
3+
ProblemFilters.exclude[ReversedMissingMethodProblem]("akka.http.scaladsl.settings.Http2ServerSettings.goawayGracePeriod")

akka-http-core/src/main/resources/reference.conf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ akka.http {
315315
# Protects against rapid reset attacks. If a connection goes over the limit, it is closed with HTTP/2 protocol error ENHANCE_YOUR_CALM
316316
max-resets = 400
317317
max-resets-interval = 10s
318+
319+
# After sending a GOAWAY frame during graceful termination, wait this long before closing the
320+
# TCP connection. This gives the client time to receive the GOAWAY frame before the connection
321+
# is torn down, avoiding a TCP RST on the client's next read. This is especially important for
322+
# idle or nearly-idle connections where the stage would otherwise complete almost immediately
323+
# after queuing the GOAWAY.
324+
goaway-grace-period = 250ms
318325
}
319326

320327
websocket {
@@ -508,6 +515,11 @@ akka.http {
508515
# requests to complete.
509516
completion-timeout = 3s
510517

518+
# After sending a GOAWAY frame, wait this long before closing the TCP connection. This gives the
519+
# peer time to receive the GOAWAY frame before the connection is torn down. Set to 0 to not wait but instead
520+
# immediately close the connection.
521+
goaway-grace-period = 250ms
522+
511523
}
512524

513525
#client-settings

akka-http-core/src/main/scala/akka/http/impl/engine/http2/Http2Demux.scala

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
217217
logic =>
218218

219219
import Http2Demux.CompletionTimeout
220+
import Http2Demux.GoAwayGracePeriod
220221

221222
def wrapTrailingHeaders(headers: ParsedHeadersFrame): Option[HttpEntity.ChunkStreamPart] = stage.wrapTrailingHeaders(headers)
222223

@@ -233,6 +234,7 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
233234

234235
private val terminationPromise = Promise[Http.HttpTerminated]()
235236
private var terminating: Boolean = false
237+
private var goAwayGracePeriodElapsed: Boolean = false
236238
private var lastIdBeforeTermination: Int = 0
237239
private val terminateCallback = getAsyncCallback[FiniteDuration](triggerTermination)
238240
override def terminate(deadline: FiniteDuration)(implicit ex: ExecutionContext): Future[Http.HttpTerminated] = {
@@ -242,11 +244,24 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
242244
private def triggerTermination(deadline: FiniteDuration): Unit =
243245
// check if we are already terminating, otherwise start termination
244246
if (!terminating) {
245-
log.debug(s"Termination of this connection was triggered. Sending GOAWAY and waiting for open requests to complete for $CompletionTimeout.")
246-
terminating = true
247-
pushGOAWAY(ErrorCode.NO_ERROR, "Voluntary connection close.")
248-
lastIdBeforeTermination = lastStreamId()
249-
completeIfDone()
247+
if (deadline == Duration.Zero) {
248+
log.debug("Termination of this connection was triggered. Sending GOAWAY and waiting for open requests to complete for {}.", CompletionTimeout)
249+
terminating = true
250+
goAwayGracePeriodElapsed = true
251+
pushGOAWAY(ErrorCode.NO_ERROR, "Voluntary connection close.")
252+
lastIdBeforeTermination = lastStreamId()
253+
completeIfDone()
254+
} else {
255+
log.debug("Termination of this connection was triggered. Sending GOAWAY and waiting for open requests to complete for {}.", deadline)
256+
terminating = true
257+
// First GOAWAY per RFC 7540 §6.8: use last-stream-id = Int.MaxValue to signal graceful
258+
// shutdown intent. The client must stop initiating new streams, but streams already in
259+
// flight before the client receives this frame may still arrive. We accept those during
260+
// the grace period by keeping lastIdBeforeTermination at Int.MaxValue for now.
261+
multiplexer.pushControlFrame(GoAwayFrame(Int.MaxValue, ErrorCode.NO_ERROR, ByteString("Voluntary connection close.")))
262+
lastIdBeforeTermination = Int.MaxValue
263+
scheduleOnce(GoAwayGracePeriod, http2Settings.goawayGracePeriod)
264+
}
250265
if (!isClosed(frameOut))
251266
scheduleOnce(CompletionTimeout, deadline)
252267
}
@@ -297,7 +312,6 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
297312
override def pushGOAWAY(errorCode: ErrorCode, debug: String): Unit = {
298313
val frame = GoAwayFrame(lastStreamId(), errorCode, ByteString(debug))
299314
multiplexer.pushControlFrame(frame)
300-
// FIXME: handle the connection closing according to the specification
301315
}
302316
private[this] var allowReadingIncomingFrames: Boolean = true
303317
override def allowReadingIncomingFrames(allow: Boolean): Unit = {
@@ -415,7 +429,10 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
415429
private def completeIfDone(): Unit = {
416430
val noMoreOutgoingStreams = (terminating || isClosed(substreamIn)) && activeStreamCount() == 0
417431
def allOutgoingDataFlushed = isClosed(frameOut) || multiplexer.hasFlushedAllData
418-
if (noMoreOutgoingStreams && allOutgoingDataFlushed) {
432+
// When terminating via GOAWAY, delay closure until the grace period has elapsed so the
433+
// client has time to receive the GOAWAY frame before we tear down the TCP connection.
434+
val gracePeriodDone = !terminating || goAwayGracePeriodElapsed
435+
if (noMoreOutgoingStreams && allOutgoingDataFlushed && gracePeriodDone) {
419436
log.debug("Closing connection after all streams are done and all data has been flushed.")
420437
if (isServer)
421438
completeStage()
@@ -488,6 +505,18 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
488505
}
489506

490507
override protected def onTimer(timerKey: Any): Unit = timerKey match {
508+
case GoAwayGracePeriod =>
509+
// Second GOAWAY per RFC 7540 §6.8: now that the grace period has elapsed, confirm the
510+
// actual last stream ID that was processed. The client must retry any streams above this
511+
// value. Only send if the connection is still open — it may have already been closed by
512+
// CompletionTimeout or a peer-initiated close during the grace period.
513+
if (!isClosed(frameOut)) {
514+
lastIdBeforeTermination = lastStreamId()
515+
pushGOAWAY(ErrorCode.NO_ERROR, "Voluntary connection close.")
516+
}
517+
goAwayGracePeriodElapsed = true
518+
completeIfDone()
519+
491520
case ConfigurablePing.Tick =>
492521
// don't do anything unless there are active streams
493522
if (activeStreamCount() > 0) {
@@ -524,4 +553,5 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, ini
524553
@InternalApi
525554
private[akka] object Http2Demux {
526555
case object CompletionTimeout
556+
case object GoAwayGracePeriod
527557
}

akka-http-core/src/main/scala/akka/http/javadsl/settings/Http2ClientSettings.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,7 @@ trait Http2ClientSettings { self: scaladsl.settings.Http2ClientSettings.Http2Cli
4646
def getMaxConnectionBackoff: Duration = Duration.ofMillis(maxConnectionBackoff.toMillis)
4747
def withMaxConnectionBackoff(backoff: Duration): Http2ClientSettings = copy(maxConnectionBackoff = backoff.toMillis.millis)
4848

49+
def getGoawayGracePeriod: Duration = Duration.ofMillis(goawayGracePeriod.toMillis)
50+
def withGoawayGracePeriod(duration: Duration): Http2ClientSettings = copy(goawayGracePeriod = duration.toMillis.millis)
51+
4952
}

akka-http-core/src/main/scala/akka/http/javadsl/settings/Http2ServerSettings.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ trait Http2ServerSettings { self: scaladsl.settings.Http2ServerSettings with akk
4848

4949
def withMaxResetsInterval(interval: Duration): Http2ServerSettings = copy(maxResetsInterval = interval.toMillis.millis)
5050

51+
def getGoawayGracePeriod: Duration = Duration.ofMillis(goawayGracePeriod.toMillis)
52+
53+
def withGoawayGracePeriod(duration: Duration): Http2ServerSettings = copy(goawayGracePeriod = duration.toMillis.millis)
54+
5155
}
5256
object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] {
5357
def create(config: Config): Http2ServerSettings = scaladsl.settings.Http2ServerSettings(config)

akka-http-core/src/main/scala/akka/http/scaladsl/settings/Http2ServerSettings.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ private[http] trait Http2CommonSettings {
3434

3535
def pingInterval: FiniteDuration
3636
def pingTimeout: FiniteDuration
37+
38+
def goawayGracePeriod: FiniteDuration
3739
}
3840

3941
/**
@@ -96,6 +98,9 @@ trait Http2ServerSettings extends javadsl.settings.Http2ServerSettings with Http
9698

9799
def withMaxResetsInterval(interval: FiniteDuration): Http2ServerSettings = copy(maxResetsInterval = interval)
98100

101+
def goawayGracePeriod: FiniteDuration
102+
def withGoawayGracePeriod(duration: FiniteDuration): Http2ServerSettings = copy(goawayGracePeriod = duration)
103+
99104
@InternalApi
100105
private[http] def internalSettings: Option[Http2InternalServerSettings]
101106
@InternalApi
@@ -120,6 +125,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] {
120125
pingTimeout: FiniteDuration,
121126
maxResets: Int,
122127
maxResetsInterval: FiniteDuration,
128+
goawayGracePeriod: FiniteDuration,
123129
internalSettings: Option[Http2InternalServerSettings]
124130
)
125131
extends Http2ServerSettings {
@@ -147,6 +153,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] {
147153
pingTimeout = c.getFiniteDuration("ping-timeout"),
148154
maxResets = c.getInt("max-resets"),
149155
maxResetsInterval = c.getFiniteDuration("max-resets-interval"),
156+
goawayGracePeriod = c.getFiniteDuration("goaway-grace-period"),
150157
internalSettings = None, // no possibility to configure internal settings with config
151158
)
152159
}
@@ -194,6 +201,9 @@ trait Http2ClientSettings extends javadsl.settings.Http2ClientSettings with Http
194201
def completionTimeout: FiniteDuration
195202
def withCompletionTimeout(timeout: FiniteDuration): Http2ClientSettings = copy(completionTimeout = timeout)
196203

204+
def goawayGracePeriod: FiniteDuration
205+
def withGoawayGracePeriod(duration: FiniteDuration): Http2ClientSettings = copy(goawayGracePeriod = duration)
206+
197207
def baseConnectionBackoff: FiniteDuration
198208
def withBaseConnectionBackoff(backoff: FiniteDuration): Http2ClientSettings = copy(baseConnectionBackoff = backoff)
199209

@@ -225,6 +235,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] {
225235
completionTimeout: FiniteDuration,
226236
baseConnectionBackoff: FiniteDuration,
227237
maxConnectionBackoff: FiniteDuration,
238+
goawayGracePeriod: FiniteDuration,
228239
internalSettings: Option[Http2InternalClientSettings])
229240
extends Http2ClientSettings with javadsl.settings.Http2ClientSettings {
230241
require(maxConcurrentStreams >= 0, "max-concurrent-streams must be >= 0")
@@ -252,6 +263,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] {
252263
completionTimeout = c.getFiniteDuration("completion-timeout"),
253264
baseConnectionBackoff = c.getFiniteDuration("base-connection-backoff"),
254265
maxConnectionBackoff = c.getFiniteDuration("max-connection-backoff"),
266+
goawayGracePeriod = c.getFiniteDuration("goaway-grace-period"),
255267
internalSettings = None // no possibility to configure internal settings with config
256268
)
257269
}

0 commit comments

Comments
 (0)