This is an automated email from the ASF dual-hosted git repository.
Cole-Greer pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
The following commit(s) were added to refs/heads/master by this push:
new cfffb3698e Aligned JS and .NET readTimeoutMillis with other GLVs
(#3524)
cfffb3698e is described below
commit cfffb3698e3c42be5df1c43eb42f4e6948a91a0c
Author: Guian Gumpac <[email protected]>
AuthorDate: Sat Jul 18 18:08:38 2026 -0700
Aligned JS and .NET readTimeoutMillis with other GLVs (#3524)
Brings the JavaScript and .NET drivers in line with Java, Python, and Go so
that `readTimeout` bounds the wait for the initial server response, not just
the response body. Previously a server that accepted the connection but never
responded was not bounded by `readTimeout` in JS or .NET, falling through to a
fixed framework default instead.
Assisted-by: Kiro: Claude Opus 4.8
---
docs/src/reference/gremlin-variants.asciidoc | 31 ++++++++--------
docs/src/upgrade/release-4.x.x.asciidoc | 5 +--
.../src/Gremlin.Net/Driver/Connection.cs | 42 ++++++++++++++++++++--
.../src/Gremlin.Net/Driver/ConnectionSettings.cs | 13 ++++---
.../Driver/ClientBehaviorIntegrationTests.cs | 25 +++++++++++++
.../Gremlin.Net.UnitTest/Driver/ConnectionTests.cs | 33 ++++++++++++++++-
.../gremlin-javascript/lib/driver/connection.ts | 2 +-
.../gremlin-javascript/lib/driver/dispatcher.ts | 3 +-
.../test/integration/client-behavior-tests.js | 23 ++++++++++--
.../test/unit/dispatcher-test.js | 11 +++++-
10 files changed, 158 insertions(+), 30 deletions(-)
diff --git a/docs/src/reference/gremlin-variants.asciidoc
b/docs/src/reference/gremlin-variants.asciidoc
index 3bc95a6fb7..c18d736f20 100644
--- a/docs/src/reference/gremlin-variants.asciidoc
+++ b/docs/src/reference/gremlin-variants.asciidoc
@@ -289,7 +289,7 @@ can be passed to the `NewClient` or
`NewDriverRemoteConnection` functions as con
|MaxIdleConnections | Maximum number of idle (keep-alive) connections in the
pool. |8
|IdleTimeoutMillis | How long in milliseconds idle connections remain in the
pool before being closed. Also settable as `IdleTimeout` (a `time.Duration`);
set only one. |180000
|KeepAliveTimeMillis | Idle time in milliseconds before TCP keep-alive probes
begin. Also settable as `KeepAliveTime` (a `time.Duration`); set only one.
|30000
-|ReadTimeoutMillis | Idle-read timeout in milliseconds reset on each read of
the response body. Set to `0` to disable. Also settable as `ReadTimeout` (a
`time.Duration`); set only one. |0
+|ReadTimeoutMillis | Idle-read timeout in milliseconds. Bounds the wait for
the initial server response and the idle time between response chunks,
resetting per chunk. It is not a whole-request deadline. Set to `0` to disable.
Also settable as `ReadTimeout` (a `time.Duration`); set only one. |0
|Compression |The wire compression negotiated with the server
(`gremlingo.CompressionNone` or `gremlingo.CompressionDeflate`).
|gremlingo.CompressionDeflate
|BatchSize |The connection-level default batch size used when a request does
not specify one. |64
|BulkResults |Connection-level default for `bulkResults`, applied to every
request unless overridden per-request. The `DriverRemoteConnection` traversal
path defaults to `true` regardless of this setting. |false
@@ -307,8 +307,8 @@ Serialization is not configurable in Gremlin-Go. Requests
are always serialized
deserialized as GraphBinary. A request body encoding other than JSON can only
be produced through a
<<gremlin-go-interceptors,request interceptor>>.
-Note that no driver timeout bounds the *total* duration of a request once it
is under way. `ReadTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it
is under way. `ReadTimeout` bounds the
+wait for the initial server response and the gap between response chunks, so a
response that keeps producing chunks will not time out no matter how long it
runs overall, and there is no client-side "overall" request timeout. If you
need an absolute deadline, impose it in
your application around the call. Because `Submit` does not accept a
`context.Context`, run it in a goroutine and
bound it with a `select`:
@@ -1105,7 +1105,7 @@ The following table describes the various configuration
options for the Gremlin
|connectionPool.maxWaitForClose |The amount of time in milliseconds to wait
for pending messages to be returned from the server before closing the
connection. |3000
|connectionPool.reconnectInterval |The amount of time in milliseconds to wait
before trying to reconnect to a dead host. |1000
|connectionPool.batchSize |The default value for the per-request batch size
used when a request does not specify one. |64
-|connectionPool.readTimeoutMillis |Idle-read timeout in milliseconds that
bounds the time between inbound response chunks. Set to `0` to disable. On
`Cluster.Builder` also settable as `readTimeout(Duration)`. |0
+|connectionPool.readTimeoutMillis |Idle-read timeout in milliseconds. Bounds
the wait for the initial server response and the idle time between response
chunks, resetting per chunk. It is not a whole-request deadline. Set to `0` to
disable. On `Cluster.Builder` also settable as `readTimeout(Duration)`. |0
|connectionPool.sslCipherSuites |The list of JSSE ciphers to support for SSL
connections. If specified, only the ciphers that are listed and supported will
be enabled. If not specified, the JVM default is used. |_none_
|connectionPool.sslEnabledProtocols |The list of SSL protocols to support for
SSL connections. If specified, only the protocols that are listed and supported
will be enabled. If not specified, the JVM default is used. |_none_
|connectionPool.sslSkipCertValidation |Configures the `TrustManager` to trust
all certs without any validation. Should not be used in production.|false
@@ -1622,8 +1622,8 @@ Responses are now streamed rather than buffered to a
fixed size, so this client-
responses are causing timeouts, increase the `readTimeout` on the driver and
consider applying server-side limits
(such as result iteration or serialization limits) to constrain the size of
responses being returned.
-Note that no driver timeout bounds the *total* duration of a request once it
is under way. `readTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it
is under way. `readTimeout` bounds the
+wait for the initial server response and the gap between response chunks, so a
response that keeps producing chunks will not time out no matter how long it
runs overall, and there is no client-side "overall" request timeout. If you
need an absolute deadline, impose it in
your application around the asynchronous API, for example by wrapping a
`CompletableFuture` with a timeout:
@@ -2022,7 +2022,7 @@ can be passed in the constructor of a new `Client` or
`DriverRemoteConnection` :
|options |Object |The connection options. |{}
|options.traversalSource |String |The name of the remote
`GraphTraversalSource`. |'g'
|options.maxConnections |Number |Caps the number of concurrent connections per
origin on the default dispatcher. |128
-|options.readTimeoutMillis |Number |A per-read idle timeout in milliseconds
(undici `bodyTimeout`). It resets per chunk, so it is safe for streaming.
|runtime default
+|options.readTimeoutMillis |Number |Idle-read timeout in milliseconds. Bounds
the wait for the initial server response and the idle time between response
chunks, resetting per chunk. It is not a whole-request deadline. Maps to undici
`headersTimeout` and `bodyTimeout`. |runtime default
|options.maxResponseHeaderBytes |Number |The maximum size of the response
headers in bytes (undici `maxHeaderSize`). |runtime default
|options.keepAliveTimeMillis |Number |Idle time in milliseconds before TCP
keep-alive probes begin. Enables `SO_KEEPALIVE` on the socket. Set to `0` to
disable. |30000
|options.proxy |String |An HTTP proxy URI. When set, requests are routed
through an undici `ProxyAgent`. |undefined
@@ -2042,8 +2042,8 @@ TLS is configured through the Node.js/undici runtime (for
example the `NODE_EXTR
`NODE_TLS_REJECT_UNAUTHORIZED` environment variables), not through driver
options. Custom headers are set via an
interceptor rather than a `headers` option, for example `interceptors: (req)
=> { req.headers['X-Custom'] = 'value'; }`.
-Note that no driver timeout bounds the *total* duration of a request once it
is under way. `readTimeoutMillis` only bounds
-the gap between response chunks, so a response that keeps producing chunks
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it
is under way. `readTimeoutMillis` bounds the
+wait for the initial server response and the gap between response chunks, so a
response that keeps producing chunks will not time out no matter how long it
runs overall, and there is no client-side "overall" request timeout. If you
need an absolute deadline, impose it in
your application around the call by racing the `submit` promise against a
timeout:
@@ -2738,7 +2738,7 @@ constructor:
|BatchSize |The connection-level default batch size used to fill the
per-request batch size when it is unset. |64
|Ssl |The `SslClientAuthenticationOptions` used for HTTPS connections (client
certificates, custom CA, protocols, etc.). `SkipCertificateValidation` is
applied to an internal copy of these options rather than mutating the object
you provide. |`null`
|MaxResponseHeaderBytes |The maximum allowed size, in bytes, of the response
headers. `0` leaves the handler default unchanged (converted internally to the
handler's native kilobyte unit). |0
-|ReadTimeoutMillis |The idle-read timeout in milliseconds applied to each
individual read of the response stream. It resets per chunk. `0` (the default)
disables it. Also settable as `ReadTimeout` (a `TimeSpan`;
`Timeout.InfiniteTimeSpan` disables). |0
+|ReadTimeoutMillis |Idle-read timeout in milliseconds. Bounds the wait for the
initial server response and the idle time between response chunks, resetting
per chunk. It is not a whole-request deadline. `0` (the default) disables it.
Also settable as `ReadTimeout` (a `TimeSpan`; `Timeout.InfiniteTimeSpan`
disables). |0
|Proxy |The `IWebProxy` used for connections. |`null`
|EnableUserAgentOnConnect |Enables sending a user agent to the server on
requests.
More details can be found in provider docs
@@ -2747,8 +2747,9 @@
link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provide
|SkipCertificateValidation |Whether to skip SSL certificate validation. Only
use for testing with self-signed certificates. When `Ssl` is also provided, the
accept-all callback is set on an internal copy so the supplied options object
is never mutated. |false
|=========================================================
-Note that no driver timeout bounds the *total* duration of a request once it
is under way. `ReadTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it
is under way. `ReadTimeout` bounds
+the wait for the initial server response and the gap between response chunks,
so a response that keeps producing
+chunks will not time out no matter how long it
runs overall, and there is no client-side "overall" request timeout. If you
need an absolute deadline, impose it in
your application by passing a cancellation token that cancels after the
deadline:
@@ -3450,7 +3451,7 @@ can be passed to the `Client` or `DriverRemoteConnection`
instance as keyword ar
|auth |An authentication interceptor. Always appended to the end of the
interceptor list so it runs last. |`None`
|max_connections |The maximum number of connections used by the pool. |128
|connect_timeout_millis |Timeout in milliseconds for establishing the
connection (TCP connect plus TLS handshake). Also settable as `connect_timeout`
(in seconds). |5000
-|read_timeout_millis |Per-read idle timeout in milliseconds applied while
streaming a response. Resets per chunk. Also settable as `read_timeout` (in
seconds). |`None`
+|read_timeout_millis |Idle-read timeout in milliseconds. Bounds the wait for
the initial server response and the idle time between response chunks,
resetting per chunk. It is not a whole-request deadline. Maps to aiohttp
`sock_read`. Also settable as `read_timeout` (in seconds). |`None`
|write_timeout |Timeout in seconds for writing a request to the transport.
|`None`
|ssl |An `ssl.SSLContext` used for TLS connections. |`None`
|idle_timeout_millis |How long in milliseconds an idle connection remains in
the pool before being closed. Also settable as `idle_timeout` (in seconds).
|180000
@@ -3479,8 +3480,8 @@ g = traversal().with_(
read_timeout=30))
----
-Note that no driver timeout bounds the *total* duration of a request once it
is under way. `read_timeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it
is under way. `read_timeout` bounds the
+wait for the initial server response and the gap between response chunks, so a
response that keeps producing chunks will not time out no matter how long it
runs overall, and there is no client-side "overall" request timeout. If you
need an absolute deadline, impose it in
your application. `submit_async()` and `ResultSet.all()` return
`concurrent.futures.Future` objects, so pass a
`timeout` to `result()`:
diff --git a/docs/src/upgrade/release-4.x.x.asciidoc
b/docs/src/upgrade/release-4.x.x.asciidoc
index bd9e9f02b1..129e66ec2b 100644
--- a/docs/src/upgrade/release-4.x.x.asciidoc
+++ b/docs/src/upgrade/release-4.x.x.asciidoc
@@ -392,8 +392,9 @@ These change runtime behavior on upgrade even if you do not
change your configur
`CompressionNone`).
- *Connect timeout lowered to 5s* (from 15s, where applicable) and is now
actually applied to transport establishment
(TCP connect plus TLS handshake), not the whole request.
-- *`readTimeout` is an idle-read timeout*, reset on each inbound response
chunk, so it is streaming-safe and bounds only
- the gap between chunks, not total response duration. It is off by default.
+- *`readTimeout` is an idle-read timeout*, armed when the request is sent and
reset on each inbound response chunk, so it
+ is streaming-safe and bounds the wait for the first response as well as the
idle gap between chunks, but not total
+ response duration. It is off by default.
- *`idleTimeout` reaps only pooled connections* that are idle between
requests; it no longer bounds an in-flight
response (that is `readTimeout`'s job).
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
index 7e16bffea9..13de107654 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
@@ -303,8 +303,7 @@ namespace Gremlin.Net.Driver
}
}
- response = await _httpClient.SendAsync(httpRequest,
- HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
+ response = await SendWithReadTimeoutAsync(httpRequest,
cancellationToken)
.ConfigureAwait(false);
}
@@ -414,6 +413,45 @@ namespace Gremlin.Net.Driver
}
}
+ /// <summary>
+ /// Sends the request and waits for the response headers, bounding
that wait by
+ /// <see cref="ConnectionSettings.ReadTimeout"/> when it is
positive. This uses the same
+ /// disambiguation idiom as <see cref="ReadTimeoutStream"/>: a
timeout
+ /// <see cref="CancellationTokenSource"/> linked with the caller
token, armed with
+ /// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>,
so a fired timeout
+ /// (when the caller token did not fire) surfaces as a <see
cref="TimeoutException"/>.
+ /// When <see cref="ConnectionSettings.ReadTimeout"/> is
non-positive the caller token is
+ /// passed straight through with no wrapping. The timeout CTS is
disposed once headers are
+ /// read so its timer does not linger into body streaming.
+ /// </summary>
+ private async Task<HttpResponseMessage>
SendWithReadTimeoutAsync(HttpRequestMessage httpRequest,
+ CancellationToken cancellationToken)
+ {
+ if (_settings.ReadTimeout <= TimeSpan.Zero)
+ {
+ return await _httpClient.SendAsync(httpRequest,
+ HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ using var timeoutCts = new CancellationTokenSource();
+ using var linkedCts =
+
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken,
timeoutCts.Token);
+ timeoutCts.CancelAfter(_settings.ReadTimeout);
+ try
+ {
+ return await _httpClient.SendAsync(httpRequest,
+ HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when
(timeoutCts.IsCancellationRequested &&
+
!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"Timed out after
{_settings.ReadTimeout.TotalSeconds:0.###}s waiting for the initial server
response.");
+ }
+ }
+
/// <summary>
/// Converts a maximum response header size expressed in bytes to
the kilobyte unit
/// used by <see
cref="SocketsHttpHandler.MaxResponseHeadersLength"/>, rounding up so
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
index 5381acc055..c3f7e37600 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
@@ -160,10 +160,12 @@ namespace Gremlin.Net.Driver
public int MaxResponseHeaderBytes { get; set; } = 0;
/// <summary>
- /// Gets or sets the idle-read timeout applied to each individual
read of the response
- /// stream. It resets per chunk, so it is an idle-read timeout
rather than a
- /// whole-request deadline. <see
cref="System.Threading.Timeout.InfiniteTimeSpan"/>
- /// (the default) disables it.
+ /// Gets or sets the read timeout. It bounds two waits: the wait
for the initial server
+ /// response (time to first byte / response headers), as a single
deadline armed when the
+ /// request is sent; and, once the response body is streaming, the
idle time before each
+ /// individual read of the stream, reset per read. It is therefore
not a whole-request
+ /// deadline and does not bound the total streaming duration once
the server has begun
+ /// responding.
/// </summary>
public TimeSpan ReadTimeout { get; set; } =
System.Threading.Timeout.InfiniteTimeSpan;
@@ -171,6 +173,9 @@ namespace Gremlin.Net.Driver
/// Gets or sets <see cref="ReadTimeout"/> in whole milliseconds,
where <c>0</c> disables it
/// (mapping to <see
cref="System.Threading.Timeout.InfiniteTimeSpan"/>). This is the millisecond
/// view of the same setting; <see cref="ReadTimeout"/> is the
idiomatic <see cref="TimeSpan"/> form.
+ /// It bounds the wait for the initial server response (time to
first byte / response
+ /// headers) as well as the idle time between response body
chunks, but it is not a
+ /// whole-request deadline.
/// </summary>
public int ReadTimeoutMillis
{
diff --git
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
index 03977423b2..dca18329ad 100644
---
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
+++
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
@@ -244,6 +244,31 @@ namespace Gremlin.Net.IntegrationTest.Driver
Assert.Single(results);
}
+ [Fact]
+ public async Task
ShouldTimeoutWhenServerNeverRespondsUsingReadTimeout()
+ {
+ SkipIfServerUnavailable();
+
+ // A short ReadTimeout must bound the wait for the initial server
response even
+ // when the caller supplies no cancellation token.
+ var settings = new ConnectionSettings { ReadTimeout =
TimeSpan.FromSeconds(2) };
+ using var timeoutClient = new GremlinClient(
+ new GremlinServer(Host, SocketServerConstants.Port),
connectionSettings: settings);
+
+ var ex = await Assert.ThrowsAsync<TimeoutException>(async () =>
+ {
+ var resultSet = await timeoutClient.SubmitAsync<dynamic>(
+ SocketServerConstants.GremlinNoResponse);
+ await resultSet.ToListAsync();
+ });
+ Assert.Contains("waiting for the initial server response",
ex.Message);
+
+ // Recovery with main client
+ var resultSet = await
_client!.SubmitAsync<dynamic>(SocketServerConstants.GremlinSingleVertex);
+ var results = await resultSet.ToListAsync();
+ Assert.Single(results);
+ }
+
[Fact]
public async Task ShouldHandleAsyncRequestsDuringConnectionClose()
{
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
index 7a3bee9e62..a15e167809 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
@@ -1168,6 +1168,26 @@ namespace Gremlin.Net.UnitTest.Driver
});
}
+ [Fact]
+ public async Task
ShouldTimeOutWhenServerNeverSendsResponseWhenReadTimeoutSet()
+ {
+ // A server that accepts the request but never sends a response
should trigger the
+ // read timeout during the header wait, so the exception surfaces
directly from
+ // SubmitAsync (before any results can be enumerated).
+ var handler = new NeverRespondingHandler();
+ var httpClient = new HttpClient(handler);
+ var serializer = CreateReadingSerializer();
+ var settings = new ConnectionSettings
+ {
+ ReadTimeout = TimeSpan.FromMilliseconds(100)
+ };
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ var ex = await Assert.ThrowsAsync<TimeoutException>(async () =>
+ await connection.SubmitAsync<object>(CreateTestRequest()));
+ Assert.Contains("waiting for the initial server response",
ex.Message);
+ }
+
private static IMessageSerializer CreateReadingSerializer(
string mimeType = SerializationTokens.GraphBinary4MimeType)
{
@@ -1311,8 +1331,19 @@ namespace Gremlin.Net.UnitTest.Driver
}
/// <summary>
- /// A stream wrapper that tracks read operations.
+ /// A mock handler whose SendAsync never returns until cancelled,
simulating a server
+ /// that accepts the request but never sends a response. Used to
exercise the read
+ /// timeout during the initial header wait.
/// </summary>
+ private class NeverRespondingHandler : HttpMessageHandler
+ {
+ protected override async Task<HttpResponseMessage>
SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ await Task.Delay(Timeout.Infinite,
cancellationToken).ConfigureAwait(false);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ }
private class TrackingStream : Stream
{
private readonly Stream _inner;
diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts
b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
index fc4e1c0bda..68f477d081 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
@@ -57,7 +57,7 @@ export type ConnectionOptions = {
enableUserAgentOnConnect?: boolean;
/** Maximum number of concurrent connections per origin. Defaults to 128. */
maxConnections?: number;
- /** Idle-read (body) timeout in milliseconds, applied to the default
dispatcher. */
+ /** Read timeout in ms, applied to the default dispatcher. Maps to undici
`headersTimeout` (wait for first response byte) and `bodyTimeout` (idle between
body chunks). */
readTimeoutMillis?: number;
/** Maximum size of the response headers in bytes, applied to the default
dispatcher. */
maxResponseHeaderBytes?: number;
diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
index 7e323aff77..4954563888 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
@@ -29,7 +29,7 @@ export const DEFAULT_KEEP_ALIVE_TIME = 30000;
export type DispatcherOptions = {
/** Max concurrent connections per origin. Defaults to {@link
DEFAULT_MAX_CONNECTIONS}. */
maxConnections?: number;
- /** Idle-read (body) timeout in ms. Maps to undici `bodyTimeout`. */
+ /** Read timeout in ms. Maps to undici `headersTimeout` (wait for first
response byte) and `bodyTimeout` (idle between body chunks). */
readTimeoutMillis?: number;
/** Max response header size in bytes. Maps to undici `maxHeaderSize`. */
maxResponseHeaderBytes?: number;
@@ -84,6 +84,7 @@ export function buildAgentOptions(options: DispatcherOptions
= {}): Agent.Option
// Connect/idle timeouts are intentionally left to undici defaults (the GLV
spec marks the JS
// connect/idle timeout as N/A), not exposed as driver options.
if (options.readTimeoutMillis !== undefined) {
+ agentOptions.headersTimeout = options.readTimeoutMillis;
agentOptions.bodyTimeout = options.readTimeoutMillis;
}
if (options.maxResponseHeaderBytes !== undefined) {
diff --git
a/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
b/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
index 98f6537aa0..43dfd27324 100644
--- a/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
+++ b/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
@@ -117,10 +117,27 @@ describe('Client Behavior', function () {
assert.ok(result.length > 0);
});
- it.skip('should timeout when server never responds - JS driver lacks
client-side idle timeout', async function () {
- const shortTimeoutClient = createClient({ requestTimeout: 1000 });
+ it('should timeout when server never responds', async function () {
+ const shortTimeoutClient = createClient({ readTimeoutMillis: 1000 });
try {
- await assert.rejects(shortTimeoutClient.submit(GREMLIN_NO_RESPONSE));
+ // undici's headersTimeout must fire well before its 300s default. The
submit path does not
+ // wrap the fetch error, so it surfaces as a WHATWG `TypeError('fetch
failed')` whose `cause`
+ // is undici's HeadersTimeoutError (`code: 'UND_ERR_HEADERS_TIMEOUT'`).
Assert both that the
+ // rejection is fast and that it is a timeout error, so the intent is
explicit.
+ const start = Date.now();
+ await assert.rejects(shortTimeoutClient.submit(GREMLIN_NO_RESPONSE),
(err) => {
+ const cause = err && err.cause;
+ const code = (cause && cause.code) || err.code;
+ const message = `${(cause && cause.message) || ''} ${err.message ||
''}`;
+ assert.ok(
+ code === 'UND_ERR_HEADERS_TIMEOUT' || /headers timeout|timeout|fetch
failed/i.test(message),
+ `expected an undici timeout error, got: ${err.stack || err}`,
+ );
+ return true;
+ });
+ const elapsed = Date.now() - start;
+ assert.ok(elapsed < 10000, `expected timeout to fire quickly, but it
took ${elapsed}ms`);
+
const result = await shortTimeoutClient.submit(GREMLIN_SINGLE_VERTEX);
assert.strictEqual(result.length, 1);
} finally {
diff --git a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
index 8cfc4add0b..a01dd55be9 100644
--- a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
+++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
@@ -76,9 +76,16 @@ describe('dispatcher', function () {
});
describe('buildAgentOptions (undici option mapping)', function () {
- it('maps readTimeoutMillis to the undici Agent bodyTimeout', function () {
+ it('maps readTimeoutMillis to the undici Agent bodyTimeout and
headersTimeout', function () {
const opts = buildAgentOptions({ readTimeoutMillis: 1234 });
assert.strictEqual(opts.bodyTimeout, 1234);
+ assert.strictEqual(opts.headersTimeout, 1234);
+ });
+
+ it('maps readTimeoutMillis 0 to bodyTimeout and headersTimeout 0 (undici
disables the timer)', function () {
+ const opts = buildAgentOptions({ readTimeoutMillis: 0 });
+ assert.strictEqual(opts.bodyTimeout, 0);
+ assert.strictEqual(opts.headersTimeout, 0);
});
it('maps maxResponseHeaderBytes to the undici Agent maxHeaderSize',
function () {
@@ -99,12 +106,14 @@ describe('dispatcher', function () {
it('omits bodyTimeout and maxHeaderSize when their options are unset',
function () {
const opts = buildAgentOptions();
assert.strictEqual(opts.bodyTimeout, undefined);
+ assert.strictEqual(opts.headersTimeout, undefined);
assert.strictEqual(opts.maxHeaderSize, undefined);
});
it('maps both readTimeoutMillis and maxResponseHeaderBytes together',
function () {
const opts = buildAgentOptions({ readTimeoutMillis: 2000,
maxResponseHeaderBytes: 8192 });
assert.strictEqual(opts.bodyTimeout, 2000);
+ assert.strictEqual(opts.headersTimeout, 2000);
assert.strictEqual(opts.maxHeaderSize, 8192);
});