forked from cerc-io/plugeth
graphql, node, rpc: improve HTTP write timeout handling (#25457)
Here we add special handling for sending an error response when the write timeout of the HTTP server is just about to expire. This is surprisingly difficult to get right, since is must be ensured that all output is fully flushed in time, which needs support from multiple levels of the RPC handler stack: The timeout response can't use chunked transfer-encoding because there is no way to write the final terminating chunk. net/http writes it when the topmost handler returns, but the timeout will already be over by the time that happens. We decided to disable chunked encoding by setting content-length explicitly. Gzip compression must also be disabled for timeout responses because we don't know the true content-length before compressing all output, i.e. compression would reintroduce chunked transfer-encoding.
This commit is contained in:
+3
-2
@@ -527,7 +527,7 @@ func (c *Client) write(ctx context.Context, msg interface{}, retry bool) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := c.writeConn.writeJSON(ctx, msg)
|
||||
err := c.writeConn.writeJSON(ctx, msg, false)
|
||||
if err != nil {
|
||||
c.writeConn = nil
|
||||
if !retry {
|
||||
@@ -660,7 +660,8 @@ func (c *Client) read(codec ServerCodec) {
|
||||
for {
|
||||
msgs, batch, err := codec.readBatch()
|
||||
if _, ok := err.(*json.SyntaxError); ok {
|
||||
codec.writeJSON(context.Background(), errorMessage(&parseError{err.Error()}))
|
||||
msg := errorMessage(&parseError{err.Error()})
|
||||
codec.writeJSON(context.Background(), msg, true)
|
||||
}
|
||||
if err != nil {
|
||||
c.readErr <- err
|
||||
|
||||
@@ -60,10 +60,15 @@ var (
|
||||
const (
|
||||
errcodeDefault = -32000
|
||||
errcodeNotificationsUnsupported = -32001
|
||||
errcodeTimeout = -32002
|
||||
errcodePanic = -32603
|
||||
errcodeMarshalError = -32603
|
||||
)
|
||||
|
||||
const (
|
||||
errMsgTimeout = "request timed out"
|
||||
)
|
||||
|
||||
type methodNotFoundError struct{ method string }
|
||||
|
||||
func (e *methodNotFoundError) ErrorCode() int { return -32601 }
|
||||
|
||||
+132
-10
@@ -91,12 +91,83 @@ func newHandler(connCtx context.Context, conn jsonWriter, idgen func() ID, reg *
|
||||
return h
|
||||
}
|
||||
|
||||
// batchCallBuffer manages in progress call messages and their responses during a batch
|
||||
// call. Calls need to be synchronized between the processing and timeout-triggering
|
||||
// goroutines.
|
||||
type batchCallBuffer struct {
|
||||
mutex sync.Mutex
|
||||
calls []*jsonrpcMessage
|
||||
resp []*jsonrpcMessage
|
||||
wrote bool
|
||||
}
|
||||
|
||||
// nextCall returns the next unprocessed message.
|
||||
func (b *batchCallBuffer) nextCall() *jsonrpcMessage {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
if len(b.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
// The popping happens in `pushAnswer`. The in progress call is kept
|
||||
// so we can return an error for it in case of timeout.
|
||||
msg := b.calls[0]
|
||||
return msg
|
||||
}
|
||||
|
||||
// pushResponse adds the response to last call returned by nextCall.
|
||||
func (b *batchCallBuffer) pushResponse(answer *jsonrpcMessage) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
if answer != nil {
|
||||
b.resp = append(b.resp, answer)
|
||||
}
|
||||
b.calls = b.calls[1:]
|
||||
}
|
||||
|
||||
// write sends the responses.
|
||||
func (b *batchCallBuffer) write(ctx context.Context, conn jsonWriter) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
b.doWrite(ctx, conn, false)
|
||||
}
|
||||
|
||||
// timeout sends the responses added so far. For the remaining unanswered call
|
||||
// messages, it sends a timeout error response.
|
||||
func (b *batchCallBuffer) timeout(ctx context.Context, conn jsonWriter) {
|
||||
b.mutex.Lock()
|
||||
defer b.mutex.Unlock()
|
||||
|
||||
for _, msg := range b.calls {
|
||||
if !msg.isNotification() {
|
||||
resp := msg.errorResponse(&internalServerError{errcodeTimeout, errMsgTimeout})
|
||||
b.resp = append(b.resp, resp)
|
||||
}
|
||||
}
|
||||
b.doWrite(ctx, conn, true)
|
||||
}
|
||||
|
||||
// doWrite actually writes the response.
|
||||
// This assumes b.mutex is held.
|
||||
func (b *batchCallBuffer) doWrite(ctx context.Context, conn jsonWriter, isErrorResponse bool) {
|
||||
if b.wrote {
|
||||
return
|
||||
}
|
||||
b.wrote = true // can only write once
|
||||
if len(b.resp) > 0 {
|
||||
conn.writeJSON(ctx, b.resp, isErrorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBatch executes all messages in a batch and returns the responses.
|
||||
func (h *handler) handleBatch(msgs []*jsonrpcMessage) {
|
||||
// Emit error response for empty batches:
|
||||
if len(msgs) == 0 {
|
||||
h.startCallProc(func(cp *callProc) {
|
||||
h.conn.writeJSON(cp.ctx, errorMessage(&invalidRequestError{"empty batch"}))
|
||||
resp := errorMessage(&invalidRequestError{"empty batch"})
|
||||
h.conn.writeJSON(cp.ctx, resp, true)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -113,16 +184,42 @@ func (h *handler) handleBatch(msgs []*jsonrpcMessage) {
|
||||
}
|
||||
// Process calls on a goroutine because they may block indefinitely:
|
||||
h.startCallProc(func(cp *callProc) {
|
||||
answers := make([]*jsonrpcMessage, 0, len(msgs))
|
||||
for _, msg := range calls {
|
||||
if answer := h.handleCallMsg(cp, msg); answer != nil {
|
||||
answers = append(answers, answer)
|
||||
var (
|
||||
timer *time.Timer
|
||||
cancel context.CancelFunc
|
||||
callBuffer = &batchCallBuffer{calls: calls, resp: make([]*jsonrpcMessage, 0, len(calls))}
|
||||
)
|
||||
|
||||
cp.ctx, cancel = context.WithCancel(cp.ctx)
|
||||
defer cancel()
|
||||
|
||||
// Cancel the request context after timeout and send an error response. Since the
|
||||
// currently-running method might not return immediately on timeout, we must wait
|
||||
// for the timeout concurrently with processing the request.
|
||||
if timeout, ok := ContextRequestTimeout(cp.ctx); ok {
|
||||
timer = time.AfterFunc(timeout, func() {
|
||||
cancel()
|
||||
callBuffer.timeout(cp.ctx, h.conn)
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
// No need to handle rest of calls if timed out.
|
||||
if cp.ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
msg := callBuffer.nextCall()
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
resp := h.handleCallMsg(cp, msg)
|
||||
callBuffer.pushResponse(resp)
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
callBuffer.write(cp.ctx, h.conn)
|
||||
h.addSubscriptions(cp.notifiers)
|
||||
if len(answers) > 0 {
|
||||
h.conn.writeJSON(cp.ctx, answers)
|
||||
}
|
||||
for _, n := range cp.notifiers {
|
||||
n.activate()
|
||||
}
|
||||
@@ -135,10 +232,36 @@ func (h *handler) handleMsg(msg *jsonrpcMessage) {
|
||||
return
|
||||
}
|
||||
h.startCallProc(func(cp *callProc) {
|
||||
var (
|
||||
responded sync.Once
|
||||
timer *time.Timer
|
||||
cancel context.CancelFunc
|
||||
)
|
||||
cp.ctx, cancel = context.WithCancel(cp.ctx)
|
||||
defer cancel()
|
||||
|
||||
// Cancel the request context after timeout and send an error response. Since the
|
||||
// running method might not return immediately on timeout, we must wait for the
|
||||
// timeout concurrently with processing the request.
|
||||
if timeout, ok := ContextRequestTimeout(cp.ctx); ok {
|
||||
timer = time.AfterFunc(timeout, func() {
|
||||
cancel()
|
||||
responded.Do(func() {
|
||||
resp := msg.errorResponse(&internalServerError{errcodeTimeout, errMsgTimeout})
|
||||
h.conn.writeJSON(cp.ctx, resp, true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
answer := h.handleCallMsg(cp, msg)
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
h.addSubscriptions(cp.notifiers)
|
||||
if answer != nil {
|
||||
h.conn.writeJSON(cp.ctx, answer)
|
||||
responded.Do(func() {
|
||||
h.conn.writeJSON(cp.ctx, answer, false)
|
||||
})
|
||||
}
|
||||
for _, n := range cp.notifiers {
|
||||
n.activate()
|
||||
@@ -334,7 +457,6 @@ func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage
|
||||
}
|
||||
start := time.Now()
|
||||
answer := h.runMethod(cp.ctx, msg, callb, args)
|
||||
|
||||
// Collect the statistics for RPC calls if metrics is enabled.
|
||||
// We only care about pure rpc call. Filter out subscription.
|
||||
if callb != h.unsubscribeCb {
|
||||
|
||||
+71
-2
@@ -23,9 +23,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -52,7 +54,7 @@ type httpConn struct {
|
||||
// and some methods don't work. The panic() stubs here exist to ensure
|
||||
// this special treatment is correct.
|
||||
|
||||
func (hc *httpConn) writeJSON(context.Context, interface{}) error {
|
||||
func (hc *httpConn) writeJSON(context.Context, interface{}, bool) error {
|
||||
panic("writeJSON called on httpConn")
|
||||
}
|
||||
|
||||
@@ -256,7 +258,42 @@ type httpServerConn struct {
|
||||
func newHTTPServerConn(r *http.Request, w http.ResponseWriter) ServerCodec {
|
||||
body := io.LimitReader(r.Body, maxRequestContentLength)
|
||||
conn := &httpServerConn{Reader: body, Writer: w, r: r}
|
||||
return NewCodec(conn)
|
||||
|
||||
encoder := func(v any, isErrorResponse bool) error {
|
||||
if !isErrorResponse {
|
||||
return json.NewEncoder(conn).Encode(v)
|
||||
}
|
||||
|
||||
// It's an error response and requires special treatment.
|
||||
//
|
||||
// In case of a timeout error, the response must be written before the HTTP
|
||||
// server's write timeout occurs. So we need to flush the response. The
|
||||
// Content-Length header also needs to be set to ensure the client knows
|
||||
// when it has the full response.
|
||||
encdata, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("content-length", strconv.Itoa(len(encdata)))
|
||||
|
||||
// If this request is wrapped in a handler that might remove Content-Length (such
|
||||
// as the automatic gzip we do in package node), we need to ensure the HTTP server
|
||||
// doesn't perform chunked encoding. In case WriteTimeout is reached, the chunked
|
||||
// encoding might not be finished correctly, and some clients do not like it when
|
||||
// the final chunk is missing.
|
||||
w.Header().Set("transfer-encoding", "identity")
|
||||
|
||||
_, err = w.Write(encdata)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(conn)
|
||||
dec.UseNumber()
|
||||
|
||||
return NewFuncCodec(conn, encoder, dec.Decode)
|
||||
}
|
||||
|
||||
// Close does nothing and always returns nil.
|
||||
@@ -326,3 +363,35 @@ func validateRequest(r *http.Request) (int, error) {
|
||||
err := fmt.Errorf("invalid content type, only %s is supported", contentType)
|
||||
return http.StatusUnsupportedMediaType, err
|
||||
}
|
||||
|
||||
// ContextRequestTimeout returns the request timeout derived from the given context.
|
||||
func ContextRequestTimeout(ctx context.Context) (time.Duration, bool) {
|
||||
timeout := time.Duration(math.MaxInt64)
|
||||
hasTimeout := false
|
||||
setTimeout := func(d time.Duration) {
|
||||
if d < timeout {
|
||||
timeout = d
|
||||
hasTimeout = true
|
||||
}
|
||||
}
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
setTimeout(time.Until(deadline))
|
||||
}
|
||||
|
||||
// If the context is an HTTP request context, use the server's WriteTimeout.
|
||||
httpSrv, ok := ctx.Value(http.ServerContextKey).(*http.Server)
|
||||
if ok && httpSrv.WriteTimeout > 0 {
|
||||
wt := httpSrv.WriteTimeout
|
||||
// When a write timeout is configured, we need to send the response message before
|
||||
// the HTTP server cuts connection. So our internal timeout must be earlier than
|
||||
// the server's true timeout.
|
||||
//
|
||||
// Note: Timeouts are sanitized to be a minimum of 1 second.
|
||||
// Also see issue: https://github.com/golang/go/issues/47229
|
||||
wt -= 100 * time.Millisecond
|
||||
setTimeout(wt)
|
||||
}
|
||||
|
||||
return timeout, hasTimeout
|
||||
}
|
||||
|
||||
+17
-9
@@ -168,18 +168,22 @@ type ConnRemoteAddr interface {
|
||||
// support for parsing arguments and serializing (result) objects.
|
||||
type jsonCodec struct {
|
||||
remote string
|
||||
closer sync.Once // close closed channel once
|
||||
closeCh chan interface{} // closed on Close
|
||||
decode func(v interface{}) error // decoder to allow multiple transports
|
||||
encMu sync.Mutex // guards the encoder
|
||||
encode func(v interface{}) error // encoder to allow multiple transports
|
||||
closer sync.Once // close closed channel once
|
||||
closeCh chan interface{} // closed on Close
|
||||
decode decodeFunc // decoder to allow multiple transports
|
||||
encMu sync.Mutex // guards the encoder
|
||||
encode encodeFunc // encoder to allow multiple transports
|
||||
conn deadlineCloser
|
||||
}
|
||||
|
||||
type encodeFunc = func(v interface{}, isErrorResponse bool) error
|
||||
|
||||
type decodeFunc = func(v interface{}) error
|
||||
|
||||
// NewFuncCodec creates a codec which uses the given functions to read and write. If conn
|
||||
// implements ConnRemoteAddr, log messages will use it to include the remote address of
|
||||
// the connection.
|
||||
func NewFuncCodec(conn deadlineCloser, encode, decode func(v interface{}) error) ServerCodec {
|
||||
func NewFuncCodec(conn deadlineCloser, encode encodeFunc, decode decodeFunc) ServerCodec {
|
||||
codec := &jsonCodec{
|
||||
closeCh: make(chan interface{}),
|
||||
encode: encode,
|
||||
@@ -198,7 +202,11 @@ func NewCodec(conn Conn) ServerCodec {
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(conn)
|
||||
dec.UseNumber()
|
||||
return NewFuncCodec(conn, enc.Encode, dec.Decode)
|
||||
|
||||
encode := func(v interface{}, isErrorResponse bool) error {
|
||||
return enc.Encode(v)
|
||||
}
|
||||
return NewFuncCodec(conn, encode, dec.Decode)
|
||||
}
|
||||
|
||||
func (c *jsonCodec) peerInfo() PeerInfo {
|
||||
@@ -228,7 +236,7 @@ func (c *jsonCodec) readBatch() (messages []*jsonrpcMessage, batch bool, err err
|
||||
return messages, batch, nil
|
||||
}
|
||||
|
||||
func (c *jsonCodec) writeJSON(ctx context.Context, v interface{}) error {
|
||||
func (c *jsonCodec) writeJSON(ctx context.Context, v interface{}, isErrorResponse bool) error {
|
||||
c.encMu.Lock()
|
||||
defer c.encMu.Unlock()
|
||||
|
||||
@@ -237,7 +245,7 @@ func (c *jsonCodec) writeJSON(ctx context.Context, v interface{}) error {
|
||||
deadline = time.Now().Add(defaultWriteTimeout)
|
||||
}
|
||||
c.conn.SetWriteDeadline(deadline)
|
||||
return c.encode(v)
|
||||
return c.encode(v, isErrorResponse)
|
||||
}
|
||||
|
||||
func (c *jsonCodec) close() {
|
||||
|
||||
+2
-1
@@ -125,7 +125,8 @@ func (s *Server) serveSingleRequest(ctx context.Context, codec ServerCodec) {
|
||||
reqs, batch, err := codec.readBatch()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
codec.writeJSON(ctx, errorMessage(&invalidMessageError{"parse error"}))
|
||||
resp := errorMessage(&invalidMessageError{"parse error"})
|
||||
codec.writeJSON(ctx, resp, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+4
-2
@@ -175,11 +175,13 @@ func (n *Notifier) activate() error {
|
||||
func (n *Notifier) send(sub *Subscription, data json.RawMessage) error {
|
||||
params, _ := json.Marshal(&subscriptionResult{ID: string(sub.ID), Result: data})
|
||||
ctx := context.Background()
|
||||
return n.h.conn.writeJSON(ctx, &jsonrpcMessage{
|
||||
|
||||
msg := &jsonrpcMessage{
|
||||
Version: vsn,
|
||||
Method: n.namespace + notificationMethodSuffix,
|
||||
Params: params,
|
||||
})
|
||||
}
|
||||
return n.h.conn.writeJSON(ctx, msg, false)
|
||||
}
|
||||
|
||||
// A Subscription is created by a notifier and tied to that notifier. The client can use
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ type ServerCodec interface {
|
||||
// jsonWriter can write JSON messages to its underlying connection.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type jsonWriter interface {
|
||||
writeJSON(context.Context, interface{}) error
|
||||
// writeJSON writes a message to the connection.
|
||||
writeJSON(ctx context.Context, msg interface{}, isError bool) error
|
||||
|
||||
// Closed returns a channel which is closed when the connection is closed.
|
||||
closed() <-chan interface{}
|
||||
// RemoteAddr returns the peer address of the connection.
|
||||
|
||||
+7
-3
@@ -287,8 +287,12 @@ func newWebsocketCodec(conn *websocket.Conn, host string, req http.Header) Serve
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
return nil
|
||||
})
|
||||
|
||||
encode := func(v interface{}, isErrorResponse bool) error {
|
||||
return conn.WriteJSON(v)
|
||||
}
|
||||
wc := &websocketCodec{
|
||||
jsonCodec: NewFuncCodec(conn, conn.WriteJSON, conn.ReadJSON).(*jsonCodec),
|
||||
jsonCodec: NewFuncCodec(conn, encode, conn.ReadJSON).(*jsonCodec),
|
||||
conn: conn,
|
||||
pingReset: make(chan struct{}, 1),
|
||||
info: PeerInfo{
|
||||
@@ -315,8 +319,8 @@ func (wc *websocketCodec) peerInfo() PeerInfo {
|
||||
return wc.info
|
||||
}
|
||||
|
||||
func (wc *websocketCodec) writeJSON(ctx context.Context, v interface{}) error {
|
||||
err := wc.jsonCodec.writeJSON(ctx, v)
|
||||
func (wc *websocketCodec) writeJSON(ctx context.Context, v interface{}, isError bool) error {
|
||||
err := wc.jsonCodec.writeJSON(ctx, v, isError)
|
||||
if err == nil {
|
||||
// Notify pingLoop to delay the next idle ping.
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user