Skip to content

Commit e6e9ac0

Browse files
Limit number of simultaneous REST connections (#3326)
## Summary This PR limits the number of simultaneous REST connections we process to prevent the exhaustion of resources and ultimately a crash. Two limits are introduced: soft and hard. When the soft limit is exceeded, new connections are returned the 429 Too Many Requests http code. When the hard limit is exceeded, new connections are accepted and immediately closed. Partially resolves https://github.com/algorand/go-algorand-internal/issues/1814. ## Test Plan Added unit tests.
1 parent cb1650e commit e6e9ac0

21 files changed

+470
-113
lines changed

agreement/gossip/networkFull_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestMain(m *testing.M) {
3838

3939
logging.Base().SetLevel(logging.Debug)
4040
// increase limit on max allowed number of sockets
41-
err := util.RaiseRlimit(500)
41+
err := util.SetFdSoftLimit(500)
4242
if err != nil {
4343
os.Exit(1)
4444
}
@@ -50,7 +50,6 @@ func spinNetwork(t *testing.T, nodesCount int) ([]*networkImpl, []*messageCounte
5050
cfg := config.GetDefaultLocal()
5151
cfg.GossipFanout = nodesCount - 1
5252
cfg.NetAddress = "127.0.0.1:0"
53-
cfg.IncomingConnectionsLimit = -1
5453
cfg.IncomingMessageFilterBucketCount = 5
5554
cfg.IncomingMessageFilterBucketSize = 32
5655
cfg.OutgoingMessageFilterBucketCount = 3

config/localTemplate.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ type Local struct {
7474
CadaverSizeTarget uint64 `version[0]:"1073741824"`
7575

7676
// IncomingConnectionsLimit specifies the max number of long-lived incoming
77-
// connections. 0 means no connections allowed. -1 is unbounded.
77+
// connections. 0 means no connections allowed. Must be non-negative.
7878
// Estimating 5MB per incoming connection, 5MB*800 = 4GB
7979
IncomingConnectionsLimit int `version[0]:"-1" version[1]:"10000" version[17]:"800"`
8080

@@ -99,9 +99,9 @@ type Local struct {
9999
PriorityPeers map[string]bool `version[4]:""`
100100

101101
// To make sure the algod process does not run out of FDs, algod ensures
102-
// that RLIMIT_NOFILE exceeds the max number of incoming connections (i.e.,
103-
// IncomingConnectionsLimit) by at least ReservedFDs. ReservedFDs are meant
104-
// to leave room for short-lived FDs like DNS queries, SQLite files, etc.
102+
// that RLIMIT_NOFILE >= IncomingConnectionsLimit + RestConnectionsHardLimit +
103+
// ReservedFDs. ReservedFDs are meant to leave room for short-lived FDs like
104+
// DNS queries, SQLite files, etc. This parameter shouldn't be changed.
105105
ReservedFDs uint64 `version[2]:"256"`
106106

107107
// local server
@@ -423,6 +423,13 @@ type Local struct {
423423

424424
// ProposalAssemblyTime is the max amount of time to spend on generating a proposal block.
425425
ProposalAssemblyTime time.Duration `version[19]:"250000000"`
426+
427+
// When the number of http connections to the REST layer exceeds the soft limit,
428+
// we start returning http code 429 Too Many Requests.
429+
RestConnectionsSoftLimit uint64 `version[20]:"1024"`
430+
// The http server does not accept new connections as long we have this many
431+
// (hard limit) connections already.
432+
RestConnectionsHardLimit uint64 `version[20]:"2048"`
426433
}
427434

428435
// DNSBootstrapArray returns an array of one or more DNS Bootstrap identifiers

config/local_defaults.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ var defaultLocal = Local{
9999
PublicAddress: "",
100100
ReconnectTime: 60000000000,
101101
ReservedFDs: 256,
102+
RestConnectionsHardLimit: 2048,
103+
RestConnectionsSoftLimit: 1024,
102104
RestReadTimeoutSeconds: 15,
103105
RestWriteTimeoutSeconds: 120,
104106
RunHosted: false,

network/wsNetwork_windows.go renamed to daemon/algod/api/server/lib/middlewares/connectionLimiter.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,35 @@
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with go-algorand. If not, see <https://www.gnu.org/licenses/>.
1616

17-
// +build windows
17+
package middlewares
1818

19-
package network
19+
import (
20+
"net/http"
2021

21-
func (wn *WebsocketNetwork) rlimitIncomingConnections() error {
22-
return nil
22+
"github.com/labstack/echo/v4"
23+
)
24+
25+
// MakeConnectionLimiter makes an echo middleware that limits the number of
26+
// simultaneous connections. All connections above the limit will be returned
27+
// the 429 Too Many Requests http error.
28+
func MakeConnectionLimiter(limit uint64) echo.MiddlewareFunc {
29+
sem := make(chan struct{}, limit)
30+
31+
return func(next echo.HandlerFunc) echo.HandlerFunc {
32+
return func(ctx echo.Context) error {
33+
select {
34+
case sem <- struct{}{}:
35+
defer func() {
36+
// If we fail to read from `sem`, just continue.
37+
select {
38+
case <-sem:
39+
default:
40+
}
41+
}()
42+
return next(ctx)
43+
default:
44+
return ctx.NoContent(http.StatusTooManyRequests)
45+
}
46+
}
47+
}
2348
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (C) 2019-2022 Algorand, Inc.
2+
// This file is part of go-algorand
3+
//
4+
// go-algorand is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as
6+
// published by the Free Software Foundation, either version 3 of the
7+
// License, or (at your option) any later version.
8+
//
9+
// go-algorand is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with go-algorand. If not, see <https://www.gnu.org/licenses/>.
16+
17+
package middlewares_test
18+
19+
import (
20+
"errors"
21+
"net/http"
22+
"net/http/httptest"
23+
"testing"
24+
25+
"github.com/labstack/echo/v4"
26+
"github.com/stretchr/testify/assert"
27+
28+
"github.com/algorand/go-algorand/daemon/algod/api/server/lib/middlewares"
29+
"github.com/algorand/go-algorand/test/partitiontest"
30+
)
31+
32+
func TestConnectionLimiterBasic(t *testing.T) {
33+
partitiontest.PartitionTest(t)
34+
35+
e := echo.New()
36+
37+
handlerCh := make(chan struct{})
38+
limit := 5
39+
handler := func(c echo.Context) error {
40+
<-handlerCh
41+
return c.String(http.StatusOK, "test")
42+
}
43+
middleware := middlewares.MakeConnectionLimiter(uint64(limit))
44+
45+
numConnections := 13
46+
for i := 0; i < 3; i++ {
47+
var recorders []*httptest.ResponseRecorder
48+
doneCh := make(chan int)
49+
errCh := make(chan error)
50+
51+
for index := 0; index < numConnections; index++ {
52+
req := httptest.NewRequest(http.MethodGet, "/", nil)
53+
rec := httptest.NewRecorder()
54+
ctx := e.NewContext(req, rec)
55+
56+
recorders = append(recorders, rec)
57+
58+
go func(index int) {
59+
err := middleware(handler)(ctx)
60+
doneCh <- index
61+
errCh <- err
62+
}(index)
63+
}
64+
65+
// Check http 429 code.
66+
for j := 0; j < numConnections-limit; j++ {
67+
index := <-doneCh
68+
assert.Equal(t, http.StatusTooManyRequests, recorders[index].Code)
69+
}
70+
71+
// Let handlers finish.
72+
for j := 0; j < limit; j++ {
73+
handlerCh <- struct{}{}
74+
}
75+
76+
// All other connections must return 200.
77+
for j := 0; j < limit; j++ {
78+
index := <-doneCh
79+
assert.Equal(t, http.StatusOK, recorders[index].Code)
80+
}
81+
82+
// Check that no errors were returned by the middleware.
83+
for i := 0; i < numConnections; i++ {
84+
assert.NoError(t, <-errCh)
85+
}
86+
}
87+
}
88+
89+
func TestConnectionLimiterForwardsError(t *testing.T) {
90+
partitiontest.PartitionTest(t)
91+
92+
handlerError := errors.New("handler error")
93+
handler := func(c echo.Context) error {
94+
return handlerError
95+
}
96+
middleware := middlewares.MakeConnectionLimiter(1)
97+
98+
err := middleware(handler)(nil)
99+
assert.ErrorIs(t, err, handlerError)
100+
}

daemon/algod/api/server/router.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ import (
8181

8282
const (
8383
apiV1Tag = "/v1"
84+
// TokenHeader is the header where we put the token.
85+
TokenHeader = "X-Algo-API-Token"
8486
)
8587

8688
// wrapCtx passes a common context to each request without a global variable.
@@ -99,11 +101,8 @@ func registerHandlers(router *echo.Echo, prefix string, routes lib.Routes, ctx l
99101
}
100102
}
101103

102-
// TokenHeader is the header where we put the token.
103-
const TokenHeader = "X-Algo-API-Token"
104-
105104
// NewRouter builds and returns a new router with our REST handlers registered.
106-
func NewRouter(logger logging.Logger, node *node.AlgorandFullNode, shutdown <-chan struct{}, apiToken string, adminAPIToken string, listener net.Listener) *echo.Echo {
105+
func NewRouter(logger logging.Logger, node *node.AlgorandFullNode, shutdown <-chan struct{}, apiToken string, adminAPIToken string, listener net.Listener, numConnectionsLimit uint64) *echo.Echo {
107106
if err := tokens.ValidateAPIToken(apiToken); err != nil {
108107
logger.Errorf("Invalid apiToken was passed to NewRouter ('%s'): %v", apiToken, err)
109108
}
@@ -118,9 +117,12 @@ func NewRouter(logger logging.Logger, node *node.AlgorandFullNode, shutdown <-ch
118117
e.Listener = listener
119118
e.HideBanner = true
120119

121-
e.Pre(middleware.RemoveTrailingSlash())
122-
e.Use(middlewares.MakeLogger(logger))
123-
e.Use(middlewares.MakeCORS(TokenHeader))
120+
e.Pre(
121+
middlewares.MakeConnectionLimiter(numConnectionsLimit),
122+
middleware.RemoveTrailingSlash())
123+
e.Use(
124+
middlewares.MakeLogger(logger),
125+
middlewares.MakeCORS(TokenHeader))
124126

125127
// Request Context
126128
ctx := lib.ReqContext{Node: node, Log: logger, Shutdown: shutdown}

daemon/algod/server.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package algod
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"io/ioutil"
2324
"net"
@@ -35,10 +36,13 @@ import (
3536
"github.com/algorand/go-algorand/config"
3637
apiServer "github.com/algorand/go-algorand/daemon/algod/api/server"
3738
"github.com/algorand/go-algorand/daemon/algod/api/server/lib"
39+
"github.com/algorand/go-algorand/data/basics"
3840
"github.com/algorand/go-algorand/data/bookkeeping"
3941
"github.com/algorand/go-algorand/logging"
4042
"github.com/algorand/go-algorand/logging/telemetryspec"
43+
"github.com/algorand/go-algorand/network/limitlistener"
4144
"github.com/algorand/go-algorand/node"
45+
"github.com/algorand/go-algorand/util"
4246
"github.com/algorand/go-algorand/util/metrics"
4347
"github.com/algorand/go-algorand/util/tokens"
4448
)
@@ -84,6 +88,34 @@ func (s *Server) Initialize(cfg config.Local, phonebookAddresses []string, genes
8488
s.log.SetLevel(logging.Level(cfg.BaseLoggerDebugLevel))
8589
setupDeadlockLogger()
8690

91+
// Check some config parameters.
92+
if cfg.RestConnectionsSoftLimit > cfg.RestConnectionsHardLimit {
93+
s.log.Warnf(
94+
"RestConnectionsSoftLimit %d exceeds RestConnectionsHardLimit %d",
95+
cfg.RestConnectionsSoftLimit, cfg.RestConnectionsHardLimit)
96+
cfg.RestConnectionsSoftLimit = cfg.RestConnectionsHardLimit
97+
}
98+
if cfg.IncomingConnectionsLimit < 0 {
99+
return fmt.Errorf(
100+
"Initialize() IncomingConnectionsLimit %d must be non-negative",
101+
cfg.IncomingConnectionsLimit)
102+
}
103+
104+
// Set large enough soft file descriptors limit.
105+
var ot basics.OverflowTracker
106+
fdRequired := ot.Add(
107+
cfg.ReservedFDs,
108+
ot.Add(uint64(cfg.IncomingConnectionsLimit), cfg.RestConnectionsHardLimit))
109+
if ot.Overflowed {
110+
return errors.New(
111+
"Initialize() overflowed when adding up ReservedFDs, IncomingConnectionsLimit " +
112+
"RestConnectionsHardLimit; decrease them")
113+
}
114+
err = util.SetFdSoftLimit(fdRequired)
115+
if err != nil {
116+
return fmt.Errorf("Initialize() err: %w", err)
117+
}
118+
87119
// configure the deadlock detector library
88120
switch {
89121
case cfg.DeadlockDetection > 0:
@@ -192,11 +224,12 @@ func (s *Server) Start() {
192224
}
193225

194226
listener, err := makeListener(addr)
195-
196227
if err != nil {
197228
fmt.Printf("Could not start node: %v\n", err)
198229
os.Exit(1)
199230
}
231+
listener = limitlistener.RejectingLimitListener(
232+
listener, cfg.RestConnectionsHardLimit, s.log)
200233

201234
addr = listener.Addr().String()
202235
server = http.Server{
@@ -205,9 +238,9 @@ func (s *Server) Start() {
205238
WriteTimeout: time.Duration(cfg.RestWriteTimeoutSeconds) * time.Second,
206239
}
207240

208-
tcpListener := listener.(*net.TCPListener)
209-
210-
e := apiServer.NewRouter(s.log, s.node, s.stopping, apiToken, adminAPIToken, tcpListener)
241+
e := apiServer.NewRouter(
242+
s.log, s.node, s.stopping, apiToken, adminAPIToken, listener,
243+
cfg.RestConnectionsSoftLimit)
211244

212245
// Set up files for our PID and our listening address
213246
// before beginning to listen to prevent 'goal node start'

installer/config.json.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
"PublicAddress": "",
7979
"ReconnectTime": 60000000000,
8080
"ReservedFDs": 256,
81+
"RestConnectionsHardLimit": 2048,
82+
"RestConnectionsSoftLimit": 1024,
8183
"RestReadTimeoutSeconds": 15,
8284
"RestWriteTimeoutSeconds": 120,
8385
"RunHosted": false,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright 2014 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
6+
// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!solaris,!windows
7+
8+
package limitlistener_test
9+
10+
func maxOpenFiles() int {
11+
return defaultMaxOpenFiles
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2015 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
6+
// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
7+
8+
package limitlistener_test
9+
10+
import "syscall"
11+
12+
func maxOpenFiles() int {
13+
var rlim syscall.Rlimit
14+
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil {
15+
return defaultMaxOpenFiles
16+
}
17+
return int(rlim.Cur)
18+
}

0 commit comments

Comments
 (0)