This is an automated email from the ASF dual-hosted git repository. Cole-Greer pushed a commit to branch GLVBehaviouralAlignment in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit d11ebfd6c3ec88ed5e70644bf847925d5b078925 Author: Cole Greer <[email protected]> AuthorDate: Tue Jun 2 12:37:34 2026 -0700 Add request timeout and empty-response handling to gremlin-go - Add a RequestTimeout option wired to http.Transport.ResponseHeaderTimeout so a server that accepts the connection but never responds surfaces a timeout instead of hanging (tinkerpop-y32). - Treat an empty HTTP response body as an error instead of returning an empty result set (tinkerpop-zar). - Unskip and tighten the corresponding behavioral tests. --- gremlin-go/driver/client.go | 7 +++++++ gremlin-go/driver/client_behavior_test.go | 32 +++++++++++++++++++------------ gremlin-go/driver/connection.go | 11 ++++++++++- gremlin-go/driver/connection_test.go | 2 ++ 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/gremlin-go/driver/client.go b/gremlin-go/driver/client.go index d5345393ad..83e5d21054 100644 --- a/gremlin-go/driver/client.go +++ b/gremlin-go/driver/client.go @@ -115,6 +115,12 @@ type ClientSettings struct { // uses http.ProxyFromEnvironment (HTTP_PROXY/HTTPS_PROXY/NO_PROXY). Proxy func(*http.Request) (*url.URL, error) + // RequestTimeout is the maximum time to wait for a response after sending a request. + // This bounds the time between finishing writing the request and receiving the response + // headers from the server. It is independent of ConnectionTimeout which only governs + // connection establishment. Set to 0 to disable (no timeout). Default: 0 (disabled). + RequestTimeout time.Duration + EnableUserAgentOnConnect bool // PDTRegistry enables automatic hydration of CompositePDT values during deserialization. @@ -183,6 +189,7 @@ func NewClient(url string, configurations ...func(settings *ClientSettings)) (*C ssl: settings.Ssl, connectTimeout: connectTimeout, readTimeout: readTimeout, + requestTimeout: settings.RequestTimeout, maxConnsPerHost: settings.MaxConnections, maxIdleConnsPerHost: settings.MaxIdleConnections, idleTimeout: idleTimeout, diff --git a/gremlin-go/driver/client_behavior_test.go b/gremlin-go/driver/client_behavior_test.go index f274c0ad2c..6e35a43f76 100644 --- a/gremlin-go/driver/client_behavior_test.go +++ b/gremlin-go/driver/client_behavior_test.go @@ -159,14 +159,10 @@ func TestShouldHandleEmptyResponseBody(t *testing.T) { done <- submitExpectErr(client, gremlinEmptyBody) }() - // The key requirement is that an empty response body does not hang. - // NOTE: Unlike the Java/Python/JS drivers (which raise an error), the Go - // driver currently treats an empty body as an empty (successful) result - // set rather than an error. This driver gap is flagged in the cross-GLV - // error-message audit (tinkerpop-8lw.6) for further consideration. select { - case <-done: - // completed without hanging - acceptable for now + case submitErr := <-done: + require.Error(t, submitErr) + assert.Contains(t, submitErr.Error(), "empty response body") case <-ctx.Done(): t.Fatal("request hung on empty response body") } @@ -186,11 +182,23 @@ func TestShouldHandleSlowResponse(t *testing.T) { } func TestShouldTimeoutWhenServerNeverResponds(t *testing.T) { - // The Go driver's ConnectionTimeout only governs connection establishment, - // not how long to wait for a response. With no client-side request/read - // timeout, a server that never responds causes an indefinite hang. Skipped - // until the driver supports a request timeout (flagged in tinkerpop-8lw.6). - t.Skip("Go driver lacks a client-side request/read timeout") + url := socketServerURL() + client, err := NewClient(url, func(settings *ClientSettings) { + settings.RequestTimeout = 2 * time.Second + }) + if err != nil { + t.Skip("Socket server not available") + } + defer client.Close() + + // Verify connectivity before testing the no-response scenario + if err := submitExpectErr(client, gremlinSingleVertex); err != nil { + t.Skip("Socket server not available") + } + + err = submitExpectErr(client, gremlinNoResponse) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") } func TestShouldHandleAsyncRequestsDuringConnectionClose(t *testing.T) { diff --git a/gremlin-go/driver/connection.go b/gremlin-go/driver/connection.go index b00852fb65..6da5429c4e 100644 --- a/gremlin-go/driver/connection.go +++ b/gremlin-go/driver/connection.go @@ -50,6 +50,7 @@ type connectionSettings struct { ssl *tls.Config connectTimeout time.Duration readTimeout time.Duration + requestTimeout time.Duration maxConnsPerHost int maxIdleConnsPerHost int idleTimeout time.Duration @@ -165,6 +166,10 @@ func newConnection(handler *logHandler, url string, connSettings *connectionSett // generic HTTP compression, so the manual decode path in getReader handles // decompression. Disable net/http's transparent (gzip-only) handling. DisableCompression: true, + // Bounds the time between finishing writing the request and receiving response + // headers. Independent of connectTimeout, which only governs connection + // establishment. Zero disables the timeout. + ResponseHeaderTimeout: connSettings.requestTimeout, } return &connection{ @@ -395,7 +400,11 @@ func (c *connection) streamToResultSet(reader io.Reader, rs ResultSet) { d = NewGraphBinaryDeserializer(reader) } if err := d.ReadHeader(); err != nil { - if err != io.EOF { + if err == io.EOF { + emptyBodyErr := fmt.Errorf("received empty response body from server") + c.logHandler.logf(Error, failedToReceiveResponse, emptyBodyErr.Error()) + rs.setError(emptyBodyErr) + } else { c.logHandler.logf(Error, failedToReceiveResponse, err.Error()) rs.setError(err) } diff --git a/gremlin-go/driver/connection_test.go b/gremlin-go/driver/connection_test.go index f3ca4094ca..6182a5bcaf 100644 --- a/gremlin-go/driver/connection_test.go +++ b/gremlin-go/driver/connection_test.go @@ -1229,6 +1229,7 @@ func TestConnectionPoolSettings(t *testing.T) { idleTimeout: 300 * time.Second, keepAliveTime: 60 * time.Second, connectTimeout: 30 * time.Second, + requestTimeout: 5 * time.Second, } conn := newConnection(newTestLogHandler(), "http://localhost:8182/gremlin", customSettings) @@ -1239,6 +1240,7 @@ func TestConnectionPoolSettings(t *testing.T) { assert.Equal(t, 256, transport.MaxConnsPerHost, "MaxConnsPerHost should be custom value") assert.Equal(t, 16, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost should be custom value") assert.Equal(t, 300*time.Second, transport.IdleConnTimeout, "IdleConnTimeout should be custom value") + assert.Equal(t, 5*time.Second, transport.ResponseHeaderTimeout, "ResponseHeaderTimeout should be custom value") }) t.Run("partial custom settings use defaults for unset values", func(t *testing.T) {
