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 4d5d571b7b Standardized `gremlin-dotnet` connection options (#3468)
4d5d571b7b is described below
commit 4d5d571b7b8f8b64a448148acdac56d5508f5062
Author: Guian Gumpac <[email protected]>
AuthorDate: Mon Jun 29 11:34:03 2026 -0700
Standardized `gremlin-dotnet` connection options (#3468)
Implements the .NET portion of the TinkerPop 4.x GLV connection-options
standardization. Renames several `ConnectionSettings` properties and the `Auth`
factory methods to canonical names (old names kept as `[Obsolete]` deprecated
aliases), aligns defaults, adds new options, and fixes two latent bugs. .NET
driver changes only; the other GLVs follow in separate PRs.
**Proposal:**
https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg
## Renames (deprecated aliases retained)
| Old | New | Default |
| ------------------------- | ---------------- | ------- |
| `MaxConnectionsPerServer` | `MaxConnections` | 128 |
| `IdleConnectionTimeout` | `IdleTimeout` | 180s |
| `ConnectionTimeout` | `ConnectTimeout` | 5s |
| `KeepAliveInterval` | `KeepAliveTime` | 30s |
| `EnableCompression` | `Compression` | - |
| `Auth.BasicAuth` | `Auth.Basic` | - |
| `Auth.SigV4Auth` | `Auth.Sigv4` | - |
The old property names and auth methods are retained as `[Obsolete]`
deprecated aliases (marked "As of release 4.0.0, ...") that delegate to the
canonical members, so existing code keeps compiling.
## Behavior changes (breaking)
* **`ConnectTimeout`** default lowered 15s → 5s.
* **`KeepAliveTime`** is now wired to a real TCP keep-alive socket option
(via `SocketsHttpHandler.ConnectCallback`) rather than the inert HTTP/2 ping
timeout, which had no effect on HTTP/1.1. It enables `SO_KEEPALIVE` and sets
the per-socket idle time on Windows, Linux, and macOS; on other platforms
keep-alive stays enabled at the OS default idle time.
* **`Compression`** is now a `{None, Deflate}` enum defaulting to `Deflate`
(compression on by default); the driver sends `Accept-Encoding: deflate` by
default. Set `Compression.None` to disable. The deprecated `EnableCompression`
`bool` is preserved only as a compatibility shim mapping `true`/`false` to
`Compression.Deflate`/`Compression.None`.
## New options
* **`Ssl`** (`SslClientAuthenticationOptions`) - adds client-certificate
and custom-CA support. `SkipCertificateValidation` is applied to an internal
copy rather than mutating the caller's options.
* **`DefaultBatchSize`** (64) - connection-level default that fills the
per-request `batchSize` when unset.
* **`MaxResponseHeaderBytes`** - the maximum response header size, in bytes
(converted internally to the handler's native kilobyte unit).
* **`ReadTimeout`** - a per-read idle timeout applied to each read of the
response stream (via per-read `CancelAfter`).
* **`Proxy`** (`IWebProxy`) - routes connections through an HTTP proxy.
* **`GremlinServer.FromUrl(string)`** / **`GremlinServer(Uri)`** -
configure the endpoint from a single URL (scheme, host, port, path), deriving
SSL from the scheme. The existing host/port/path constructor remains.
## Removed (breaking)
* **`maxResponseContentLength`** - responses now stream; `ReadTimeout` is
the partial mitigation.
## Bug fixes
* Fixed deflate response decompression, which threw on the server's
zlib-framed output because it used `DeflateStream` (raw DEFLATE, RFC 1951)
instead of `ZLibStream` (zlib, RFC 1950). The bug was previously masked because
compression was off by default; turning it on by default would have made it a
default-path failure.
* Fixed `Ssl` options cloning (used on the skip-certificate-validation
path) to copy `ClientCertificateContext` and `AllowTlsResume`, which were
previously dropped, breaking mTLS client certificates and silently re-enabling
TLS resumption.
## Testing
* `gremlin-dotnet` unit tests pass (695 tests), including new
`ConnectionSettingsTests` and the `ReadTimeout` slow-read timeout test.
* CHANGELOG, reference config table (`gremlin-variants.asciidoc`), and
upgrade guide (`release-4.x.x.asciidoc`) updated for the .NET slice.
Assisted-by: Kiro: Claude Opus 4.8
---
CHANGELOG.asciidoc | 13 +
docs/src/reference/gremlin-variants.asciidoc | 42 ++-
docs/src/upgrade/release-4.x.x.asciidoc | 40 +++
gremlin-dotnet/Examples/Connections/Connections.cs | 4 +-
gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 5 +-
.../src/Gremlin.Net/Driver/Compression.cs | 107 ++++++++
.../src/Gremlin.Net/Driver/Connection.cs | 211 ++++++++++++++-
.../src/Gremlin.Net/Driver/ConnectionSettings.cs | 126 +++++++--
.../src/Gremlin.Net/Driver/GremlinServer.cs | 64 +++++
.../Gremlin.Net/Driver/Messages/RequestMessage.cs | 15 ++
.../src/Gremlin.Net/Driver/ReadTimeoutStream.cs | 111 ++++++++
.../Gremlin.Net/Driver/StreamingResponseContext.cs | 12 +-
.../CompressionBenchmarks.cs | 4 +-
.../Docs/Reference/GremlinVariantsTests.cs | 2 +-
.../Driver/AuthIntegrationTests.cs | 6 +-
.../test/Gremlin.Net.UnitTest/Driver/AuthTests.cs | 28 +-
.../Driver/ConnectionSettingsTests.cs | 125 +++++++++
.../Gremlin.Net.UnitTest/Driver/ConnectionTests.cs | 290 ++++++++++++++++++++-
.../Driver/GremlinClientTests.cs | 2 +-
.../Driver/GremlinServerTests.cs | 136 ++++++++++
20 files changed, 1264 insertions(+), 79 deletions(-)
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 1902d3b8a7..c77c4062b9 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -25,6 +25,19 @@
image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
[[release-4-0-0]]
=== TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET)
+* Standardized `gremlin-dotnet` connection options per the TinkerPop 4.x GLV
proposal:
+** Renamed the `Auth.BasicAuth`/`Auth.SigV4Auth` factory methods to
`Auth.Basic`/`Auth.Sigv4` (breaking; the old methods have been removed).
*(breaking)*
+** Renamed `MaxConnectionsPerServer` to `MaxConnections`,
`IdleConnectionTimeout` to `IdleTimeout`, and `KeepAliveInterval` to
`KeepAliveTime` (now wired to a real TCP keep-alive socket option rather than
the inert HTTP/2 ping timeout, enabling `SO_KEEPALIVE` and setting the
per-socket idle time on Windows, Linux, and macOS, with a no-op fallback to the
OS default idle time on other platforms); the old property names have been
removed. *(breaking)*
+** Renamed `ConnectionTimeout` to `ConnectTimeout` (default lowered from 15s
to 5s; old name removed). *(breaking)*
+** Renamed `EnableCompression` to `Compression`, now a `{None, Deflate}` enum
defaulting to `Deflate` (compression on by default; set `None` to disable). The
old `EnableCompression` `bool` has been removed. *(breaking)*
+** Added `Ssl` (an `SslClientAuthenticationOptions`;
`SkipCertificateValidation` is applied to an internal copy rather than mutating
the caller's options).
+** Added `BatchSize` (default 64), a connection-level default that fills the
per-request `batchSize` when unset.
+** Added `MaxResponseHeaderBytes`, exposing the handler's maximum response
header size.
+** Added `ReadTimeout`, a per-read idle timeout applied to each read of the
response stream.
+** Each timeout option is also settable in milliseconds via an `int` companion
property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`, `ReadTimeoutMillis`,
`KeepAliveTimeMillis`); the unsuffixed `TimeSpan` property remains the
idiomatic form and both reflect the same value.
+** Added `Proxy`, routing connections through an `IWebProxy`.
+* Fixed `gremlin-dotnet` deflate response decompression, which threw on the
server's zlib-framed output because it used `DeflateStream` (raw DEFLATE, RFC
1951) instead of `ZLibStream` (zlib, RFC 1950); the bug was previously masked
because compression was off by default.
+* Fixed `gremlin-dotnet` SSL options cloning (used on the
skip-certificate-validation path) to copy `ClientCertificateContext` and
`AllowTlsResume`, which were previously dropped, breaking mTLS client
certificates and silently re-enabling TLS resumption.
* Standardized `gremlin-python` connection options per the TinkerPop 4.x GLV
proposal:
** Renamed `pool_size` to `max_connections` (breaking; the old name has been
removed) and changed the default from 8 to 128; `max_connections` is now also
applied to the aiohttp `TCPConnector` `limit` so the transport layer reflects
the option in addition to sizing the Connection pool.
** Renamed `ssl_options` to `ssl` accepting an `ssl.SSLContext` (breaking; the
old name has been removed).
diff --git a/docs/src/reference/gremlin-variants.asciidoc
b/docs/src/reference/gremlin-variants.asciidoc
index eb3e324a98..941e2e83dd 100644
--- a/docs/src/reference/gremlin-variants.asciidoc
+++ b/docs/src/reference/gremlin-variants.asciidoc
@@ -2589,7 +2589,7 @@
include::../../../gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference
Authentication is handled through request interceptors. Interceptors are
functions that modify the outgoing HTTP
request before it is sent — they are used for authentication, custom headers,
or request signing. The `Auth` class
-provides `BasicAuth()` and `SigV4Auth()` as built-in interceptors:
+provides `Basic()` and `SigV4()` as built-in interceptors:
[source,csharp]
----
@@ -2597,12 +2597,12 @@ provides `BasicAuth()` and `SigV4Auth()` as built-in
interceptors:
var server = new GremlinServer("localhost", 8182, enableSsl: true);
using var client = new GremlinClient(server,
connectionSettings: new ConnectionSettings { SkipCertificateValidation =
true },
- interceptors: new[] { Auth.BasicAuth("username", "password") });
+ interceptors: new[] { Auth.Basic("username", "password") });
// SigV4 authentication
var server = new GremlinServer("localhost", 8182, enableSsl: true);
using var client = new GremlinClient(server,
- interceptors: new[] { Auth.SigV4Auth("service-region", "service-name") });
+ interceptors: new[] { Auth.Sigv4("service-region", "service-name") });
----
If you authenticate to a remote <<connecting-gremlin-server,Gremlin Server>> or
@@ -2658,18 +2658,36 @@ constructor:
[width="100%",cols="3,10,^2",options="header"]
|=========================================================
|Key |Description |Default
-|ConnectionTimeout |The TCP connection timeout. |15 s
-|IdleConnectionTimeout |How long idle connections stay in the pool before
being closed. |180 s
-|MaxConnectionsPerServer |The maximum concurrent connections to a single
server. |128
-|KeepAliveInterval |The TCP keep-alive probe interval. |30 s
-|EnableCompression |Whether to request deflate compression. |false
+|ConnectTimeoutMillis |The TCP connect timeout in milliseconds (transport
establishment, i.e. TCP connect plus TLS handshake where applicable, not an
HTTP request timeout). Also settable as `ConnectTimeout` (a `TimeSpan`). |5000
+|IdleTimeoutMillis |How long in milliseconds idle connections stay in the pool
before being closed. Also settable as `IdleTimeout` (a `TimeSpan`). |180000
+|MaxConnections |The maximum concurrent connections to a single server. |128
+|KeepAliveTimeMillis |Idle time in milliseconds before TCP keep-alive probes
begin on an otherwise idle connection. Enables `SO_KEEPALIVE` on the socket and
sets the per-socket idle time on Windows, Linux, and macOS; on other platforms
keep-alive stays enabled at the OS default idle time. Also settable as
`KeepAliveTime` (a `TimeSpan`). |30000
+|Compression |The response compression algorithm (`Compression.None` or
`Compression.Deflate`). |`Compression.Deflate`
+|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
+|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
link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|true
|BulkResults |Whether to send the bulkResults header on all requests. |false
-|SkipCertificateValidation |Whether to skip SSL certificate validation. Only
use for testing with self-signed certificates. |false
+|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
+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:
+
+[source,csharp]
+----
+// bound the entire request (submit plus full result iteration) to 30 seconds
+using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+var results = await client.SubmitAsync<Vertex>(
+ "g.V().out().out()", cancellationToken: cts.Token);
+----
+
==== GremlinClient Settings
The following options can be passed to the `GremlinClient` constructor:
@@ -2699,7 +2717,7 @@ A request interceptor is a `Func<HttpRequestContext,
Task>` that mutates the `Ht
`Task` for async support but does not produce a value). A list of these is
maintained and will be run sequentially for
each request. When creating a `GremlinClient`, the `interceptors` parameter
accepts an ordered collection of
interceptors. Order matters, so if one interceptor depends on another's
output, ensure they are added in the correct
-order. Note that authentication (e.g. `Auth.BasicAuth()`, `Auth.SigV4Auth()`)
is also implemented using interceptors.
+order. Note that authentication (e.g. `Auth.Basic()`, `Auth.Sigv4()`) is also
implemented using interceptors.
These factory methods return interceptor delegates that can be included in the
`interceptors` list. Alternatively, the
`auth` parameter on `GremlinClient` appends the auth interceptor to the end of
the list so it runs last.
@@ -2709,7 +2727,7 @@ var server = new GremlinServer("localhost", 8182);
using var client = new GremlinClient(server,
interceptors: new Func<HttpRequestContext, Task>[]
{
- Auth.BasicAuth("username", "password"),
+ Auth.Basic("username", "password"),
context =>
{
context.Headers["X-Custom-Header"] = "value";
@@ -2881,7 +2899,7 @@ order and are useful for authentication, custom headers,
or request signing.
[source,csharp]
----
var client = new GremlinClient(new GremlinServer("localhost", 8182),
- interceptors: new[] { Auth.BasicAuth("username", "password") });
+ interceptors: new[] { Auth.Basic("username", "password") });
----
When `requestSerializer` is set to `null`, the request body is passed as a
`RequestMessage` to interceptors, and an
diff --git a/docs/src/upgrade/release-4.x.x.asciidoc
b/docs/src/upgrade/release-4.x.x.asciidoc
index 8481489614..0c1a3ca37d 100644
--- a/docs/src/upgrade/release-4.x.x.asciidoc
+++ b/docs/src/upgrade/release-4.x.x.asciidoc
@@ -32,6 +32,46 @@ complete list of all the modifications that are part of this
release.
=== Upgrading for Users
+==== Standardizing .NET Connection Options
+
+TinkerPop 4.x standardizes connection option names and defaults across the
GLVs. In `gremlin-dotnet`, several
+`ConnectionSettings` properties and the `Auth` factory methods have been
renamed for consistency. Because this is a
+major version, the old names have been removed rather than retained as
aliases, and a number of new options have been
+added. The notes below describe the .NET changes. See <<glv-driver-changes,
GLV Driver Changes>> for the
+equivalent changes in the other drivers.
+
+Renames (breaking). The following members have been renamed and the old names
removed. Migrate to the new names:
+
+- `ConnectionTimeout` is now `ConnectTimeout`.
+- `MaxConnectionsPerServer` is now `MaxConnections`.
+- `IdleConnectionTimeout` is now `IdleTimeout`.
+- `KeepAliveInterval` is now `KeepAliveTime`.
+- `EnableCompression` is now `Compression`.
+- The `Auth.BasicAuth` and `Auth.SigV4Auth` factory methods are now
`Auth.Basic` and `Auth.Sigv4`.
+
+Behavior changes. These change runtime behavior on upgrade, even if you do not
change your configuration:
+
+- `ConnectTimeout` now defaults to 5s (lowered from 15s).
+- `KeepAliveTime` is now wired to a real TCP keep-alive socket option rather
than the inert HTTP/2 ping timeout. It
+enables `SO_KEEPALIVE` and sets the per-socket idle time on Windows, Linux,
and macOS; on other platforms keep-alive
+stays enabled at the OS default idle time.
+- `Compression` is now a `{None, Deflate}` enum defaulting to `Deflate`
(compression on by default), so the driver
+sends `Accept-Encoding: deflate` by default. Set `Compression.None` to
disable. The old `EnableCompression` `bool`
+has been removed.
+
+New options:
+
+- `Ssl` (an `SslClientAuthenticationOptions` for client certificates and
custom CAs; `SkipCertificateValidation` is
+applied to an internal copy rather than mutating the supplied options).
+- `BatchSize` (default 64): a connection-level default that fills the
per-request batch size when unset.
+- `MaxResponseHeaderBytes`: the maximum allowed size, in bytes, of the
response headers.
+- `ReadTimeout`: a per-read idle timeout applied to each individual read of
the response stream.
+- Each timeout is also settable in milliseconds via an `int` companion
property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`,
+ `ReadTimeoutMillis`, `KeepAliveTimeMillis`); the unsuffixed `TimeSpan`
property is the idiomatic form.
+- `Proxy`: routes connections through an `IWebProxy`.
+
+See:
link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS]
Standardizing GLV connection options in TinkerPop 4].
+
==== Standardizing Go Connection Options
TinkerPop 4.x standardizes connection option names and defaults across the
GLVs. In `gremlin-go`, several
diff --git a/gremlin-dotnet/Examples/Connections/Connections.cs
b/gremlin-dotnet/Examples/Connections/Connections.cs
index bcfda7e4f8..8e7354c201 100644
--- a/gremlin-dotnet/Examples/Connections/Connections.cs
+++ b/gremlin-dotnet/Examples/Connections/Connections.cs
@@ -54,7 +54,7 @@ public class ConnectionExample
var server = new GremlinServer(ServerHost, ServerPort);
var settings = new ConnectionSettings
{
- ConnectionTimeout = TimeSpan.FromSeconds(30),
+ ConnectTimeout = TimeSpan.FromSeconds(30),
};
using var remoteConnection = new DriverRemoteConnection(
new GremlinClient(server, connectionSettings: settings), "g");
@@ -71,7 +71,7 @@ public class ConnectionExample
var server = new GremlinServer(ServerHost, SecureServerPort,
enableSsl: true);
var client = new GremlinClient(server,
connectionSettings: new ConnectionSettings {
SkipCertificateValidation = true },
- interceptors: new[] { Auth.BasicAuth("stephen", "password") });
+ interceptors: new[] { Auth.Basic("stephen", "password") });
using var remoteConnection = new DriverRemoteConnection(client, "g");
var g = Traversal().With(remoteConnection);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
index eaf706f6dd..d713dc9473 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
@@ -43,7 +43,7 @@ namespace Gremlin.Net.Driver
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
/// <returns>A request interceptor delegate.</returns>
- public static Func<HttpRequestContext, Task> BasicAuth(string
username, string password)
+ public static Func<HttpRequestContext, Task> Basic(string username,
string password)
{
var encoded = Convert.ToBase64String(
Encoding.UTF8.GetBytes(username + ":" + password));
@@ -68,7 +68,7 @@ namespace Gremlin.Net.Driver
/// Optional AWS credentials. When null, the default credential
chain is used.
/// </param>
/// <returns>A request interceptor delegate.</returns>
- public static Func<HttpRequestContext, Task> SigV4Auth(
+ public static Func<HttpRequestContext, Task> Sigv4(
string region, string service, AWSCredentials? credentials = null)
{
// Cache the credential provider once when using the default chain.
@@ -105,6 +105,7 @@ namespace Gremlin.Net.Driver
};
}
+
private static void SignRequest(HttpRequestContext context,
ImmutableCredentials credentials, AWS4Signer signer,
SigningClientConfig clientConfig)
{
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs
new file mode 100644
index 0000000000..15bc0e5853
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs
@@ -0,0 +1,107 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+
+namespace Gremlin.Net.Driver
+{
+ /// <summary>
+ /// The compression algorithm requested for server responses. The
server currently
+ /// supports <see cref="Deflate"/> only; additional members are
reserved for when the
+ /// server adds support for them (server-side first).
+ /// </summary>
+ public enum CompressionType
+ {
+ /// <summary>
+ /// No compression.
+ /// </summary>
+ None,
+
+ /// <summary>
+ /// Deflate compression.
+ /// </summary>
+ Deflate
+ }
+
+ /// <summary>
+ /// Configures response compression. A <see cref="CompressionType"/>
is implicitly
+ /// convertible (<see cref="CompressionType.Deflate"/> = <see
cref="Deflate"/>,
+ /// <see cref="CompressionType.None"/> = <see cref="None"/>).
+ /// </summary>
+ public readonly struct Compression : IEquatable<Compression>
+ {
+ /// <summary>
+ /// Gets the configured compression algorithm.
+ /// </summary>
+ public CompressionType Type { get; }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="Compression"/>
struct.
+ /// </summary>
+ /// <param name="type">The compression algorithm.</param>
+ public Compression(CompressionType type)
+ {
+ Type = type;
+ }
+
+ /// <summary>
+ /// No compression.
+ /// </summary>
+ public static Compression None => new
Compression(CompressionType.None);
+
+ /// <summary>
+ /// Deflate compression.
+ /// </summary>
+ public static Compression Deflate => new
Compression(CompressionType.Deflate);
+
+ /// <summary>
+ /// Gets whether compression is enabled (i.e. the algorithm is not
<see cref="CompressionType.None"/>).
+ /// </summary>
+ public bool Enabled => Type != CompressionType.None;
+
+ /// <summary>
+ /// Implicitly converts a <see cref="CompressionType"/> to a <see
cref="Compression"/>.
+ /// </summary>
+ /// <param name="type">The compression algorithm.</param>
+ public static implicit operator Compression(CompressionType type) =>
+ new Compression(type);
+
+ /// <inheritdoc />
+ public bool Equals(Compression other) => Type == other.Type;
+
+ /// <inheritdoc />
+ public override bool Equals(object? obj) => obj is Compression other
&& Equals(other);
+
+ /// <inheritdoc />
+ public override int GetHashCode() => (int)Type;
+
+ /// <summary>Determines whether two <see cref="Compression"/> values
are equal.</summary>
+ public static bool operator ==(Compression left, Compression right) =>
left.Equals(right);
+
+ /// <summary>Determines whether two <see cref="Compression"/> values
are not equal.</summary>
+ public static bool operator !=(Compression left, Compression right) =>
!left.Equals(right);
+
+ /// <inheritdoc />
+ public override string ToString() => Type.ToString();
+ }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
index 82a0d17724..7e16bffea9 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
@@ -67,18 +67,125 @@ namespace Gremlin.Net.Driver
var handler = new SocketsHttpHandler
{
- PooledConnectionIdleTimeout = settings.IdleConnectionTimeout,
- MaxConnectionsPerServer = settings.MaxConnectionsPerServer,
- ConnectTimeout = settings.ConnectionTimeout,
- KeepAlivePingTimeout = settings.KeepAliveInterval,
+ PooledConnectionIdleTimeout = settings.IdleTimeout,
+ MaxConnectionsPerServer = settings.MaxConnections,
+ ConnectTimeout = settings.ConnectTimeout,
};
- if (settings.SkipCertificateValidation)
+
+ // Rewire keep-alive to a real TCP socket option (HTTP/1.1). The
handler's
+ // KeepAlivePingTimeout only applies to HTTP/2; instead open the
socket ourselves
+ // in a ConnectCallback and set the TCP keep-alive idle time.
Probe interval and
+ // count stay at OS defaults (not standardized).
+ var keepAliveTime = settings.KeepAliveTime;
+ handler.ConnectCallback = async (context, cancellationToken) =>
+ {
+ // Resolve the endpoint to concrete IP addresses and attempt
each with its own
+ // socket. A single Socket cannot be reused across connection
attempts (handing a
+ // multi-address DnsEndPoint to one socket throws "Sockets on
this platform are
+ // invalid for use after a failed connection attempt"), so a
fresh socket per
+ // address is required to support round-robin/fallback DNS.
+ var endpoint = context.DnsEndPoint;
+ System.Net.IPAddress[] addresses;
+ if (System.Net.IPAddress.TryParse(endpoint.Host, out var
literal))
+ {
+ addresses = new[] { literal };
+ }
+ else
+ {
+ addresses = await System.Net.Dns.GetHostAddressesAsync(
+ endpoint.Host,
cancellationToken).ConfigureAwait(false);
+ }
+
+ if (addresses.Length == 0)
+ {
+ throw new System.Net.Sockets.SocketException(
+ (int)System.Net.Sockets.SocketError.HostNotFound);
+ }
+
+ System.Exception? lastError = null;
+ foreach (var address in addresses)
+ {
+ var socket = new System.Net.Sockets.Socket(
+ address.AddressFamily,
+ System.Net.Sockets.SocketType.Stream,
+ System.Net.Sockets.ProtocolType.Tcp)
+ {
+ NoDelay = true
+ };
+ try
+ {
+
socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Socket,
+ System.Net.Sockets.SocketOptionName.KeepAlive,
true);
+ var keepAliveSeconds = (int)keepAliveTime.TotalSeconds;
+ if (keepAliveSeconds > 0)
+ {
+ // Set the idle time before the first keep-alive
probe. Windows/Linux use
+ // the TcpKeepAliveTime enum; macOS uses the
equivalent raw TCP_KEEPALIVE
+ // option. Other platforms keep the OS default
idle time.
+ if (OperatingSystem.IsWindows() ||
OperatingSystem.IsLinux())
+ {
+
socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp,
+
System.Net.Sockets.SocketOptionName.TcpKeepAliveTime, keepAliveSeconds);
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ // TCP_KEEPALIVE on macOS (<sys/socket.h>:
0x10) is the idle-time knob,
+ // the analog of Linux TCP_KEEPIDLE.
+ const int tcpKeepAliveMacOs = 0x10;
+
socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp,
+
(System.Net.Sockets.SocketOptionName)tcpKeepAliveMacOs, keepAliveSeconds);
+ }
+ }
+ await socket.ConnectAsync(
+ new System.Net.IPEndPoint(address, endpoint.Port),
cancellationToken)
+ .ConfigureAwait(false);
+ return new System.Net.Sockets.NetworkStream(socket,
ownsSocket: true);
+ }
+ catch (System.Exception ex)
+ {
+ socket.Dispose();
+ lastError = ex;
+ }
+ }
+
+ throw lastError ?? new System.Net.Sockets.SocketException(
+ (int)System.Net.Sockets.SocketError.HostUnreachable);
+ };
+
+ // Configure SSL/TLS. Start from the user-supplied options (if
any) so client
+ // certificates, custom CAs, and protocol settings are preserved.
When
+ // SkipCertificateValidation is set we must NOT mutate the
caller's options object
+ // (it is a reference type that may be shared across clients);
instead we clone it
+ // and install the accept-all callback on the copy.
+ if (settings.Ssl != null || settings.SkipCertificateValidation)
{
- handler.SslOptions = new
System.Net.Security.SslClientAuthenticationOptions
+ System.Net.Security.SslClientAuthenticationOptions sslOptions;
+ if (settings.SkipCertificateValidation)
{
- RemoteCertificateValidationCallback = (_, _, _, _) => true,
- };
+ sslOptions = CloneSslOptions(settings.Ssl);
+ sslOptions.RemoteCertificateValidationCallback = (_, _, _,
_) => true;
+ }
+ else
+ {
+ sslOptions = settings.Ssl!;
+ }
+ handler.SslOptions = sslOptions;
}
+
+ // Expose the max response header size. The native handler unit is
kilobytes while
+ // the user provides bytes, so convert (rounding up to avoid
silently lowering the cap).
+ if (settings.MaxResponseHeaderBytes > 0)
+ {
+ handler.MaxResponseHeadersLength =
+
MaxResponseHeaderBytesToKilobytes(settings.MaxResponseHeaderBytes);
+ }
+
+ if (settings.Proxy != null)
+ {
+ handler.Proxy = settings.Proxy;
+ handler.UseProxy = true;
+ }
+
_httpClient = new HttpClient(handler);
}
@@ -111,7 +218,18 @@ namespace Gremlin.Net.Driver
var headers = new Dictionary<string, string>();
headers["Accept"] = _responseSerializer.MimeType;
- if (_settings.EnableCompression)
+ // Fill the per-request batch size from the connection-level
default when the
+ // request did not set one. Build a copy for the outgoing request
so the caller's
+ // RequestMessage is never mutated (resubmitting the same message
must not pick up
+ // a previously injected default). A per-request explicit
batchSize always wins.
+ var outgoingMessage = requestMessage;
+ if (!outgoingMessage.Fields.ContainsKey(Tokens.ArgsBatchSize))
+ {
+ outgoingMessage = outgoingMessage.CloneWithField(
+ Tokens.ArgsBatchSize, _settings.BatchSize);
+ }
+
+ if (_settings.Compression.Type == CompressionType.Deflate)
{
headers["Accept-Encoding"] = "deflate";
}
@@ -129,13 +247,13 @@ namespace Gremlin.Net.Driver
// Promote transactionId to HTTP header before interceptors run.
// The field remains in the serialized body as well (dual
transmission
// per the HTTP transaction protocol specification).
- if (requestMessage.Fields.TryGetValue(Tokens.ArgsTransactionId,
out var txIdObj) &&
+ if (outgoingMessage.Fields.TryGetValue(Tokens.ArgsTransactionId,
out var txIdObj) &&
txIdObj is string txId && !string.IsNullOrEmpty(txId))
{
headers["X-Transaction-Id"] = txId;
}
- var context = new HttpRequestContext("POST", _uri, headers,
requestMessage);
+ var context = new HttpRequestContext("POST", _uri, headers,
outgoingMessage);
foreach (var interceptor in _interceptors)
{
@@ -212,13 +330,26 @@ namespace Gremlin.Net.Driver
{
var contentStream = await response.Content.ReadAsStreamAsync()
.ConfigureAwait(false);
- DeflateStream? deflateStream = null;
+
+ // Apply the per-read idle timeout (if configured) to the raw
content stream so it
+ // covers both the compressed and decompressed read paths.
+ if (_settings.ReadTimeout > TimeSpan.Zero)
+ {
+ contentStream = new ReadTimeoutStream(contentStream,
_settings.ReadTimeout);
+ }
+
+ // The server (gremlin-server HttpContentCompressionHandler)
compresses with
+ // java.util.zip.Deflater's default constructor, which emits a
zlib-wrapped
+ // stream (RFC 1950: 2-byte header + Adler-32 checksum), not
raw DEFLATE
+ // (RFC 1951). ZLibStream understands that wrapper;
DeflateStream would throw
+ // on the zlib header.
+ Stream? decompressionStream = null;
if
(response.Content.Headers.ContentEncoding.Contains("deflate"))
{
- deflateStream = new DeflateStream(contentStream,
CompressionMode.Decompress);
+ decompressionStream = new ZLibStream(contentStream,
CompressionMode.Decompress);
}
streamingContext = new StreamingResponseContext(
- response, contentStream, deflateStream);
+ response, contentStream, decompressionStream);
var resultStream = _responseSerializer.DeserializeMessageAsync(
streamingContext.Stream, cancellationToken);
@@ -283,6 +414,58 @@ namespace Gremlin.Net.Driver
}
}
+ /// <summary>
+ /// Converts a maximum response header size expressed in bytes to
the kilobyte unit
+ /// used by <see
cref="SocketsHttpHandler.MaxResponseHeadersLength"/>, rounding up so
+ /// the configured byte cap is never silently lowered. For example
1024 bytes maps to
+ /// 1 KB, 1025 bytes maps to 2 KB, and 8192 bytes maps to 8 KB.
Callers only invoke
+ /// this when <paramref name="maxResponseHeaderBytes"/> is
positive.
+ /// </summary>
+ /// <param name="maxResponseHeaderBytes">The header cap in bytes
(expected to be positive).</param>
+ /// <returns>The equivalent cap in kilobytes, rounded up.</returns>
+ internal static int MaxResponseHeaderBytesToKilobytes(int
maxResponseHeaderBytes)
+ {
+ return (maxResponseHeaderBytes + 1023) / 1024;
+ }
+
+ /// <summary>
+ /// Creates a shallow copy of the supplied
+ /// <see
cref="System.Net.Security.SslClientAuthenticationOptions"/> so the caller's
+ /// object is never mutated when the skip-cert convenience is
applied. Copies the
+ /// commonly used properties; the accept-all
+ /// <see
cref="System.Net.Security.SslClientAuthenticationOptions.RemoteCertificateValidationCallback"/>
+ /// is set on the returned copy by the caller.
+ /// </summary>
+ /// <param name="source">The caller-owned options to clone, or
<c>null</c>.</param>
+ /// <returns>A new options instance carrying the copied
settings.</returns>
+ private static System.Net.Security.SslClientAuthenticationOptions
CloneSslOptions(
+ System.Net.Security.SslClientAuthenticationOptions? source)
+ {
+ var clone = new
System.Net.Security.SslClientAuthenticationOptions();
+ if (source == null)
+ {
+ return clone;
+ }
+
+ clone.ClientCertificates = source.ClientCertificates;
+ clone.EnabledSslProtocols = source.EnabledSslProtocols;
+ clone.TargetHost = source.TargetHost;
+ // RemoteCertificateValidationCallback is intentionally NOT copied
here: the caller
+ // overwrites it with the accept-all callback (skip-cert is the
only path that clones).
+ clone.LocalCertificateSelectionCallback =
source.LocalCertificateSelectionCallback;
+ clone.CipherSuitesPolicy = source.CipherSuitesPolicy;
+ clone.EncryptionPolicy = source.EncryptionPolicy;
+ clone.ApplicationProtocols = source.ApplicationProtocols;
+ clone.CertificateRevocationCheckMode =
source.CertificateRevocationCheckMode;
+ clone.AllowRenegotiation = source.AllowRenegotiation;
+ // ClientCertificateContext carries the mTLS client certificate
chain; omitting it
+ // would break client-certificate auth when combined with
skip-cert.
+ clone.ClientCertificateContext = source.ClientCertificateContext;
+ // AllowTlsResume defaults to true, so it must be copied to honor
a caller's false.
+ clone.AllowTlsResume = source.AllowTlsResume;
+ return clone;
+ }
+
/// <summary>
/// Attempts to extract an error message from a JSON response body.
/// The server sometimes responds with a JSON object containing a
"message" field
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
index 83faebf7ed..5381acc055 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
@@ -22,6 +22,8 @@
#endregion
using System;
+using System.Net;
+using System.Net.Security;
namespace Gremlin.Net.Driver
{
@@ -31,49 +33,91 @@ namespace Gremlin.Net.Driver
public class ConnectionSettings
{
/// <summary>
- /// The default TCP connection timeout.
+ /// The default TCP connect timeout (transport establishment, i.e.
TCP connect plus
+ /// TLS handshake where applicable - not an HTTP request timeout).
/// </summary>
- public static readonly TimeSpan DefaultConnectionTimeout =
TimeSpan.FromSeconds(15);
+ public static readonly TimeSpan DefaultConnectTimeout =
TimeSpan.FromSeconds(5);
/// <summary>
/// The default idle connection timeout.
/// </summary>
- public static readonly TimeSpan DefaultIdleConnectionTimeout =
TimeSpan.FromSeconds(180);
+ public static readonly TimeSpan DefaultIdleTimeout =
TimeSpan.FromSeconds(180);
/// <summary>
- /// The default maximum connections per server.
+ /// The default maximum concurrent connections to a single server.
/// </summary>
- public const int DefaultMaxConnectionsPerServer = 128;
+ public const int DefaultMaxConnections = 128;
/// <summary>
- /// The default TCP keep-alive probe interval.
+ /// The default TCP keep-alive idle time (how long a connection is
idle before the
+ /// first keep-alive probe is sent).
/// </summary>
- public static readonly TimeSpan DefaultKeepAliveInterval =
TimeSpan.FromSeconds(30);
+ public static readonly TimeSpan DefaultKeepAliveTime =
TimeSpan.FromSeconds(30);
/// <summary>
- /// Gets or sets the TCP connection timeout.
+ /// The default batch size used to fill the per-request batch size
when it is unset.
/// </summary>
- public TimeSpan ConnectionTimeout { get; set; } =
DefaultConnectionTimeout;
+ public const int DefaultBatchSizeValue = 64;
+
+ /// <summary>
+ /// Gets or sets the TCP connect timeout. This is a
transport-establishment timeout
+ /// (TCP connect plus TLS handshake where applicable), not an HTTP
request timeout.
+ /// </summary>
+ public TimeSpan ConnectTimeout { get; set; } = DefaultConnectTimeout;
+
+ /// <summary>
+ /// Gets or sets <see cref="ConnectTimeout"/> in whole
milliseconds. This is the millisecond
+ /// view of the same setting; <see cref="ConnectTimeout"/> is the
idiomatic <see cref="TimeSpan"/> form.
+ /// </summary>
+ public int ConnectTimeoutMillis
+ {
+ get => (int) ConnectTimeout.TotalMilliseconds;
+ set => ConnectTimeout = TimeSpan.FromMilliseconds(value);
+ }
/// <summary>
/// Gets or sets how long idle connections stay in the pool before
being closed.
/// </summary>
- public TimeSpan IdleConnectionTimeout { get; set; } =
DefaultIdleConnectionTimeout;
+ public TimeSpan IdleTimeout { get; set; } = DefaultIdleTimeout;
+
+ /// <summary>
+ /// Gets or sets <see cref="IdleTimeout"/> in whole milliseconds.
This is the millisecond
+ /// view of the same setting; <see cref="IdleTimeout"/> is the
idiomatic <see cref="TimeSpan"/> form.
+ /// </summary>
+ public int IdleTimeoutMillis
+ {
+ get => (int) IdleTimeout.TotalMilliseconds;
+ set => IdleTimeout = TimeSpan.FromMilliseconds(value);
+ }
/// <summary>
/// Gets or sets the maximum concurrent connections to a single
server.
/// </summary>
- public int MaxConnectionsPerServer { get; set; } =
DefaultMaxConnectionsPerServer;
+ public int MaxConnections { get; set; } = DefaultMaxConnections;
+
+ /// <summary>
+ /// Gets or sets the TCP keep-alive idle time: how long a
connection may be idle
+ /// before the first keep-alive probe is sent. Probe interval and
count stay at OS
+ /// defaults.
+ /// </summary>
+ public TimeSpan KeepAliveTime { get; set; } = DefaultKeepAliveTime;
/// <summary>
- /// Gets or sets the TCP keep-alive probe interval.
+ /// Gets or sets <see cref="KeepAliveTime"/> in whole
milliseconds. This is the millisecond
+ /// view of the same setting; <see cref="KeepAliveTime"/> is the
idiomatic <see cref="TimeSpan"/> form.
/// </summary>
- public TimeSpan KeepAliveInterval { get; set; } =
DefaultKeepAliveInterval;
+ public int KeepAliveTimeMillis
+ {
+ get => (int) KeepAliveTime.TotalMilliseconds;
+ set => KeepAliveTime = TimeSpan.FromMilliseconds(value);
+ }
/// <summary>
- /// Gets or sets whether to request deflate compression.
+ /// Gets or sets the response compression algorithm. Defaults to
+ /// <see cref="Driver.Compression.Deflate"/> (on); set <see
cref="Driver.Compression.None"/>
+ /// to disable.
/// </summary>
- public bool EnableCompression { get; set; } = false;
+ public Compression Compression { get; set; } = Compression.Deflate;
/// <summary>
/// Gets or sets whether to send the User-Agent header.
@@ -85,10 +129,60 @@ namespace Gremlin.Net.Driver
/// </summary>
public bool BulkResults { get; set; } = false;
+ /// <summary>
+ /// Gets or sets the connection-level default batch size that
fills the per-request batch size
+ /// when it is not set on the request (client-side
default-filling; no wire change).
+ /// </summary>
+ public int BatchSize { get; set; } = DefaultBatchSizeValue;
+
+ /// <summary>
+ /// Gets or sets the SSL/TLS options used for HTTPS connections
(client certificates,
+ /// custom CA, protocols, etc.). When set, these options are used
to configure the
+ /// underlying handler. <see cref="SkipCertificateValidation"/> is
applied to an
+ /// internal copy of these options rather than mutating the object
provided here.
+ /// </summary>
+ public SslClientAuthenticationOptions? Ssl { get; set; }
+
/// <summary>
/// Gets or sets whether to skip SSL certificate validation.
- /// Only use for testing with self-signed certificates.
+ /// Only use for testing with self-signed certificates. When <see
cref="Ssl"/> is also
+ /// provided, the accept-all callback is set on an internal copy
of those options so the
+ /// caller's <see cref="SslClientAuthenticationOptions"/> instance
is never mutated
+ /// (which is important when one instance is shared across
multiple clients).
/// </summary>
public bool SkipCertificateValidation { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the maximum allowed size, in bytes, of the
response headers.
+ /// A value of <c>0</c> (the default) leaves the handler default
unchanged. The native
+ /// handler unit is kilobytes; the byte value provided here is
converted internally.
+ /// </summary>
+ 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.
+ /// </summary>
+ public TimeSpan ReadTimeout { get; set; } =
System.Threading.Timeout.InfiniteTimeSpan;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public int ReadTimeoutMillis
+ {
+ get => ReadTimeout <= TimeSpan.Zero ? 0 : (int)
ReadTimeout.TotalMilliseconds;
+ set => ReadTimeout = value <= 0 ?
System.Threading.Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(value);
+ }
+
+ /// <summary>
+ /// Gets or sets the HTTP proxy used for connections. When set, it
is applied to the
+ /// underlying handler explicitly.
+ /// </summary>
+ public IWebProxy? Proxy { get; set; }
+
}
}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
index 0d8a41dfd1..0e023cfa5c 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
@@ -43,6 +43,62 @@ namespace Gremlin.Net.Driver
Uri = CreateUri(hostname, port, enableSsl, path);
}
+ /// <summary>
+ /// Creates a new instance of the <see cref="GremlinServer" />
class from a single URL.
+ /// </summary>
+ /// <param name="url">
+ /// The URL of the Gremlin endpoint, e.g.
<c>https://localhost:8182/gremlin</c>. The scheme determines
+ /// whether SSL is enabled (<c>https</c> enables it, <c>http</c>
disables it) and the host, port and path
+ /// are taken from the URL. When the URL omits the port the
default <c>8182</c> is used, and when it omits
+ /// the path the default <c>/gremlin</c> is used.
+ /// </param>
+ /// <returns>A new <see cref="GremlinServer" /> configured from the
given URL.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref
name="url" /> is null.</exception>
+ /// <exception cref="ArgumentException">
+ /// Thrown when <paramref name="url" /> is not a valid absolute
URL or does not use the
+ /// <c>http</c> or <c>https</c> scheme.
+ /// </exception>
+ public static GremlinServer FromUrl(string url)
+ {
+ if (url == null) throw new ArgumentNullException(nameof(url));
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ throw new ArgumentException($"'{url}' is not a valid absolute
URL.", nameof(url));
+ return new GremlinServer(uri);
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GremlinServer" />
class from a <see cref="System.Uri" />.
+ /// </summary>
+ /// <param name="uri">
+ /// The URI of the Gremlin endpoint. The scheme determines whether
SSL is enabled (<c>https</c> enables it,
+ /// <c>http</c> disables it). When the URI omits the port the
default <c>8182</c> is used, and when it omits
+ /// the path the default <c>/gremlin</c> is used.
+ /// </param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref
name="uri" /> is null.</exception>
+ /// <exception cref="ArgumentException">
+ /// Thrown when <paramref name="uri" /> does not use the
<c>http</c> or <c>https</c> scheme.
+ /// </exception>
+ public GremlinServer(Uri uri)
+ {
+ if (uri == null) throw new ArgumentNullException(nameof(uri));
+ ValidateScheme(uri.Scheme);
+
+ var enableSsl = string.Equals(uri.Scheme, "https",
StringComparison.OrdinalIgnoreCase);
+
+ // Only override the port when the URL specifies one; otherwise
keep the default 8182.
+ // System.Uri auto-fills the scheme default port (80/443) and
flags it via IsDefaultPort,
+ // so treat that case as "not specified".
+ var port = uri.IsDefaultPort ? 8182 : uri.Port;
+
+ // Likewise, only override the path when the URL has a non-empty
path, otherwise keep the default
+ // /gremlin. System.Uri turns a path-less URL into AbsolutePath
"/", so treat "/" or empty as default.
+ var path = string.IsNullOrEmpty(uri.AbsolutePath) ||
uri.AbsolutePath == "/"
+ ? "/gremlin"
+ : uri.AbsolutePath;
+
+ Uri = CreateUri(uri.Host, port, enableSsl, path);
+ }
+
/// <summary>
/// Gets the URI of the Gremlin Server.
/// </summary>
@@ -53,5 +109,13 @@ namespace Gremlin.Net.Driver
var scheme = enableSsl ? "https" : "http";
return new Uri($"{scheme}://{hostname}:{port}{path}");
}
+
+ private static void ValidateScheme(string scheme)
+ {
+ if (!string.Equals(scheme, "http",
StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(scheme, "https",
StringComparison.OrdinalIgnoreCase))
+ throw new ArgumentException(
+ $"Unsupported scheme '{scheme}'. Only 'http' and 'https'
are supported.");
+ }
}
}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs
index 2ebd02cc29..e843f43d9e 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs
@@ -52,6 +52,21 @@ namespace Gremlin.Net.Driver.Messages
/// </summary>
public Dictionary<string, object> Fields { get; }
+ /// <summary>
+ /// Returns a copy of this message with <paramref name="key"/> set
to
+ /// <paramref name="value"/>, without mutating this instance (and
therefore without
+ /// mutating the caller-owned message). Used to fill
connection-level defaults onto
+ /// the outgoing request only.
+ /// </summary>
+ /// <param name="key">The field key to set on the copy.</param>
+ /// <param name="value">The field value to set on the copy.</param>
+ /// <returns>A new <see cref="RequestMessage"/> carrying the added
field.</returns>
+ internal RequestMessage CloneWithField(string key, object value)
+ {
+ var copiedFields = new Dictionary<string, object>(Fields) { [key]
= value };
+ return new RequestMessage(Gremlin, copiedFields);
+ }
+
/// <summary>
/// Initializes a <see cref="Builder" /> to build a <see
cref="RequestMessage" />.
/// </summary>
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs
new file mode 100644
index 0000000000..aa006ed6a0
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs
@@ -0,0 +1,111 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Gremlin.Net.Driver
+{
+ /// <summary>
+ /// Wraps a stream and applies an idle-read timeout to each individual
read by linking a
+ /// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/> with
the caller's token.
+ /// The timeout resets per chunk, so it is an idle-read timeout rather
than a whole-request
+ /// deadline. A non-positive timeout disables the behavior.
+ /// </summary>
+ internal sealed class ReadTimeoutStream : Stream
+ {
+ private readonly Stream _inner;
+ private readonly TimeSpan _readTimeout;
+
+ public ReadTimeoutStream(Stream inner, TimeSpan readTimeout)
+ {
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+ _readTimeout = readTimeout;
+ }
+
+ private bool TimeoutEnabled => _readTimeout > TimeSpan.Zero;
+
+ public override async ValueTask<int> ReadAsync(Memory<byte> buffer,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TimeoutEnabled)
+ {
+ return await _inner.ReadAsync(buffer,
cancellationToken).ConfigureAwait(false);
+ }
+
+ using var timeoutCts = new CancellationTokenSource();
+ using var linkedCts =
+
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken,
timeoutCts.Token);
+ timeoutCts.CancelAfter(_readTimeout);
+ try
+ {
+ return await _inner.ReadAsync(buffer,
linkedCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when
(timeoutCts.IsCancellationRequested &&
+
!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"Read timed out after {_readTimeout.TotalSeconds:0.###}s
waiting for response data.");
+ }
+ }
+
+ public override Task<int> ReadAsync(byte[] buffer, int offset, int
count,
+ CancellationToken cancellationToken)
+ {
+ return ReadAsync(buffer.AsMemory(offset, count),
cancellationToken).AsTask();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ // The driver always reads asynchronously; provide a correct sync
fallback.
+ return _inner.Read(buffer, offset, count);
+ }
+
+ public override bool CanRead => _inner.CanRead;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush() => _inner.Flush();
+ public override long Seek(long offset, SeekOrigin origin) => throw new
NotSupportedException();
+ public override void SetLength(long value) => throw new
NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs
index 9d40e1f39f..a61ed2c647 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs
@@ -36,26 +36,26 @@ namespace Gremlin.Net.Driver
{
private readonly HttpResponseMessage _response;
private readonly Stream _contentStream;
- private readonly DeflateStream? _deflateStream;
+ private readonly Stream? _decompressionStream;
/// <summary>
/// Gets the stream to read from — the decompression stream if
present,
/// otherwise the raw content stream.
/// </summary>
- public Stream Stream => (Stream?)_deflateStream ?? _contentStream;
+ public Stream Stream => _decompressionStream ?? _contentStream;
/// <summary>
/// Initializes a new instance of the <see
cref="StreamingResponseContext"/> class.
/// </summary>
/// <param name="response">The HTTP response message.</param>
/// <param name="contentStream">The raw content stream from the
response.</param>
- /// <param name="deflateStream">An optional deflate decompression
stream wrapping the content stream.</param>
+ /// <param name="decompressionStream">An optional decompression stream
wrapping the content stream.</param>
public StreamingResponseContext(HttpResponseMessage response, Stream
contentStream,
- DeflateStream? deflateStream = null)
+ Stream? decompressionStream = null)
{
_response = response;
_contentStream = contentStream;
- _deflateStream = deflateStream;
+ _decompressionStream = decompressionStream;
}
/// <summary>
@@ -63,7 +63,7 @@ namespace Gremlin.Net.Driver
/// </summary>
public void Dispose()
{
- _deflateStream?.Dispose();
+ _decompressionStream?.Dispose();
_contentStream.Dispose();
_response.Dispose();
}
diff --git
a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs
b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs
index ac51e17f0a..8e8abf462b 100644
--- a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs
@@ -34,14 +34,14 @@ public class CompressionBenchmarks
public static async Task GraphBinaryWithoutCompression()
{
var client = new GremlinClient(new GremlinServer("localhost", 45940),
- connectionSettings: new ConnectionSettings { EnableCompression =
false });
+ connectionSettings: new ConnectionSettings { Compression =
Compression.None });
await PerformBenchmarkWithClient(client);
}
public static async Task GraphBinaryWithCompression()
{
var client = new GremlinClient(new GremlinServer("localhost", 45940),
- connectionSettings: new ConnectionSettings { EnableCompression =
true });
+ connectionSettings: new ConnectionSettings { Compression =
Compression.Deflate });
await PerformBenchmarkWithClient(client);
}
diff --git
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
index be2506151e..b67d7a7ecb 100644
---
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
+++
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
@@ -143,7 +143,7 @@ var username = "username";
var password = "password";
var gremlinServer = new GremlinServer("localhost", 8182, enableSsl: true);
using var gremlinClient = new GremlinClient(gremlinServer,
- interceptors: new[] { Auth.BasicAuth(username, password) });
+ interceptors: new[] { Auth.Basic(username, password) });
// end::submittingScriptsWithAuthentication[]
}
diff --git
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
index 3b5c9eed07..ca564f987a 100644
---
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
+++
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
@@ -53,7 +53,7 @@ namespace Gremlin.Net.IntegrationTest.Driver
{
// The secure server uses SimpleAuthenticator with credentials:
stephen/password
using var gremlinClient = CreateSecureClient(
- new[] { Auth.BasicAuth("stephen", "password") });
+ new[] { Auth.Basic("stephen", "password") });
var response = await
gremlinClient.SubmitAsync<long>("g.inject(1).count()");
@@ -65,7 +65,7 @@ namespace Gremlin.Net.IntegrationTest.Driver
{
// Test through DriverRemoteConnection + traversal
using var client = CreateSecureClient(
- new[] { Auth.BasicAuth("stephen", "password") });
+ new[] { Auth.Basic("stephen", "password") });
using var remote = new DriverRemoteConnection(client, "gmodern");
var g = AnonymousTraversalSource.Traversal().With(remote);
@@ -78,7 +78,7 @@ namespace Gremlin.Net.IntegrationTest.Driver
public async Task ShouldFailWithWrongCredentials()
{
using var gremlinClient = CreateSecureClient(
- new[] { Auth.BasicAuth("stephen", "wrongpassword") });
+ new[] { Auth.Basic("stephen", "wrongpassword") });
// The server returns auth errors as JSON (not GraphBinary), so
Connection
// extracts the message and throws HttpRequestException.
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
index 40f5239e44..76c9910d7f 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
@@ -43,7 +43,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldSetCorrectAuthorizationHeader()
{
- var interceptor = Auth.BasicAuth("user", "pass");
+ var interceptor = Auth.Basic("user", "pass");
var context = CreateTestContext();
await interceptor(context);
@@ -55,7 +55,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldSetHeaderOnEveryInvocation()
{
- var interceptor = Auth.BasicAuth("user", "pass");
+ var interceptor = Auth.Basic("user", "pass");
var context1 = CreateTestContext();
var context2 = CreateTestContext();
@@ -70,7 +70,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldHandleColonsInPassword()
{
- var interceptor = Auth.BasicAuth("user", "pass:with:colons");
+ var interceptor = Auth.Basic("user", "pass:with:colons");
var context = CreateTestContext();
await interceptor(context);
@@ -83,7 +83,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldHandleUnicodeCharacters()
{
- var interceptor = Auth.BasicAuth("用户", "密码");
+ var interceptor = Auth.Basic("用户", "密码");
var context = CreateTestContext();
await interceptor(context);
@@ -96,7 +96,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader()
{
- var interceptor = Auth.BasicAuth("user", "pass");
+ var interceptor = Auth.Basic("user", "pass");
var context = CreateTestContext();
context.Headers["Authorization"] = "Bearer old-token";
@@ -109,7 +109,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task BasicAuthShouldHandleEmptyCredentials()
{
- var interceptor = Auth.BasicAuth("", "");
+ var interceptor = Auth.Basic("", "");
var context = CreateTestContext();
await interceptor(context);
@@ -140,7 +140,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task SigV4AuthShouldAddRequiredHeaders()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext();
await interceptor(context);
@@ -154,7 +154,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix()
{
- var interceptor = Auth.SigV4Auth("gremlin-west-2",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-west-2", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext();
await interceptor(context);
@@ -166,7 +166,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task
SigV4AuthShouldAddSessionTokenForTemporaryCredentials()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestSessionCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestSessionCredentials);
var context = CreateSigv4TestContext();
await interceptor(context);
@@ -178,7 +178,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task
SigV4AuthShouldNotAddSessionTokenForPermanentCredentials()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext();
await interceptor(context);
@@ -190,7 +190,7 @@ namespace Gremlin.Net.UnitTest.Driver
public async Task SigV4AuthContentHashShouldMatchBodySha256()
{
var body = new byte[] { 0x84, 0x00, 0xFD, 0x01 };
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext(body);
await interceptor(context);
@@ -204,7 +204,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task SigV4AuthShouldHandleEmptyBody()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext(Array.Empty<byte>());
await interceptor(context);
@@ -217,7 +217,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task SigV4AuthShouldSetCorrectHost()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = CreateSigv4TestContext();
await interceptor(context);
@@ -228,7 +228,7 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray()
{
- var interceptor = Auth.SigV4Auth("gremlin-east-1",
"tinkerpop-sigv4", TestBasicCredentials);
+ var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4",
TestBasicCredentials);
var context = new HttpRequestContext("POST", new
Uri("https://example.com:8182/gremlin"),
new Dictionary<string, string>
{
diff --git
a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs
new file mode 100644
index 0000000000..19d37e93cc
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs
@@ -0,0 +1,125 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Net.Security;
+using System.Threading;
+using Gremlin.Net.Driver;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Driver
+{
+ public class ConnectionSettingsTests
+ {
+ [Fact]
+ public void ShouldUseStandardizedDefaults()
+ {
+ var settings = new ConnectionSettings();
+
+ Assert.Equal(TimeSpan.FromSeconds(5), settings.ConnectTimeout);
+ Assert.Equal(TimeSpan.FromSeconds(180), settings.IdleTimeout);
+ Assert.Equal(128, settings.MaxConnections);
+ Assert.Equal(TimeSpan.FromSeconds(30), settings.KeepAliveTime);
+ Assert.Equal(64, settings.BatchSize);
+ Assert.Equal(Compression.Deflate, settings.Compression);
+ Assert.False(settings.SkipCertificateValidation);
+ Assert.Null(settings.Ssl);
+ Assert.Null(settings.Proxy);
+ Assert.Equal(0, settings.MaxResponseHeaderBytes);
+ Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout);
+ }
+
+ [Fact]
+ public void CompressionShouldDefaultToDeflate()
+ {
+ Assert.Equal(CompressionType.Deflate, new
ConnectionSettings().Compression.Type);
+ Assert.True(new ConnectionSettings().Compression.Enabled);
+ }
+
+ [Fact]
+ public void CompressionShouldAcceptEnumValue()
+ {
+ var settings = new ConnectionSettings { Compression =
CompressionType.Deflate };
+
+ Assert.Equal(Compression.Deflate, settings.Compression);
+ }
+
+ [Fact]
+ public void CompressionEqualityShouldWork()
+ {
+ Assert.Equal(Compression.Deflate, Compression.Deflate);
+ Assert.NotEqual(Compression.Deflate, Compression.None);
+ Assert.True(Compression.None == CompressionType.None);
+ Assert.True(Compression.Deflate != Compression.None);
+ }
+
+ [Fact]
+ public void ShouldAllowSettingSslOptions()
+ {
+ var ssl = new SslClientAuthenticationOptions { TargetHost =
"example.com" };
+ var settings = new ConnectionSettings { Ssl = ssl };
+
+ Assert.Same(ssl, settings.Ssl);
+ }
+
+ [Fact]
+ public void MillisOptionsShouldSetTheTimeSpanProperties()
+ {
+ var settings = new ConnectionSettings
+ {
+ ConnectTimeoutMillis = 2000,
+ IdleTimeoutMillis = 60000,
+ KeepAliveTimeMillis = 15000,
+ ReadTimeoutMillis = 30000
+ };
+
+ Assert.Equal(TimeSpan.FromMilliseconds(2000),
settings.ConnectTimeout);
+ Assert.Equal(TimeSpan.FromMilliseconds(60000),
settings.IdleTimeout);
+ Assert.Equal(TimeSpan.FromMilliseconds(15000),
settings.KeepAliveTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(30000),
settings.ReadTimeout);
+ }
+
+ [Fact]
+ public void MillisOptionsShouldReflectTheTimeSpanProperties()
+ {
+ var settings = new ConnectionSettings
+ {
+ ConnectTimeout = TimeSpan.FromSeconds(2),
+ ReadTimeout = TimeSpan.FromSeconds(30)
+ };
+
+ Assert.Equal(2000, settings.ConnectTimeoutMillis);
+ Assert.Equal(30000, settings.ReadTimeoutMillis);
+ }
+
+ [Fact]
+ public void ReadTimeoutMillisZeroShouldDisableTheReadTimeout()
+ {
+ var settings = new ConnectionSettings { ReadTimeoutMillis = 0 };
+
+ Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout);
+ Assert.Equal(0, settings.ReadTimeoutMillis);
+ }
+
+ }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
index 5601029363..7a3bee9e62 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
@@ -141,7 +141,7 @@ namespace Gremlin.Net.UnitTest.Driver
{
var (httpClient, handler) = CreateMockHttpClient();
var serializer = CreateMockSerializer();
- var settings = new ConnectionSettings { EnableCompression = true };
+ var settings = new ConnectionSettings { Compression =
Compression.Deflate };
using var connection = new Connection(TestUri, serializer,
settings, httpClient);
await connection.SubmitAsync<object>(CreateTestRequest());
@@ -156,7 +156,7 @@ namespace Gremlin.Net.UnitTest.Driver
{
var (httpClient, handler) = CreateMockHttpClient();
var serializer = CreateMockSerializer();
- var settings = new ConnectionSettings { EnableCompression = false
};
+ var settings = new ConnectionSettings { Compression =
Compression.None };
using var connection = new Connection(TestUri, serializer,
settings, httpClient);
await connection.SubmitAsync<object>(CreateTestRequest());
@@ -166,6 +166,21 @@ namespace Gremlin.Net.UnitTest.Driver
e => e.Value == "deflate");
}
+ [Fact]
+ public async Task ShouldSetAcceptEncodingByDefault()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ await connection.SubmitAsync<object>(CreateTestRequest());
+
+ Assert.NotNull(handler.CapturedRequest);
+ Assert.Contains(handler.CapturedRequest!.Headers.AcceptEncoding,
+ e => e.Value == "deflate");
+ }
+
[Fact]
public async Task ShouldSetUserAgentWhenEnabled()
{
@@ -226,21 +241,24 @@ namespace Gremlin.Net.UnitTest.Driver
[Fact]
public async Task ShouldDecompressDeflateResponse()
{
- // Compress the minimal response bytes with deflate
+ // Compress the minimal response bytes the way the server does:
java.util.zip.Deflater's
+ // default constructor emits a zlib-wrapped stream (RFC 1950:
2-byte header + Adler-32),
+ // which corresponds to .NET's ZLibStream (NOT the raw RFC 1951
DeflateStream). Using
+ // ZLibStream here exercises the real wire format and would catch
a raw/zlib mismatch.
var originalBytes = BuildMinimalResponseBytes();
byte[] compressedBytes;
using (var compressedStream = new MemoryStream())
{
- using (var deflateStream = new DeflateStream(compressedStream,
CompressionMode.Compress, true))
+ using (var zlibStream = new ZLibStream(compressedStream,
CompressionMode.Compress, true))
{
- deflateStream.Write(originalBytes, 0,
originalBytes.Length);
+ zlibStream.Write(originalBytes, 0, originalBytes.Length);
}
compressedBytes = compressedStream.ToArray();
}
var (httpClient, handler) = CreateMockHttpClient(compressedBytes,
"deflate");
var serializer = CreateMockSerializer();
- var settings = new ConnectionSettings { EnableCompression = true };
+ var settings = new ConnectionSettings { Compression =
Compression.Deflate };
using var connection = new Connection(TestUri, serializer,
settings, httpClient);
// Should not throw — decompression should work
@@ -262,6 +280,110 @@ namespace Gremlin.Net.UnitTest.Driver
connection.Dispose();
}
+ [Fact]
+ public void ShouldNotMutateUserSslOptionsWhenSkippingCertValidation()
+ {
+ // The public constructor must NOT mutate the caller's
SslClientAuthenticationOptions
+ // when SkipCertificateValidation is set. The options object is a
reference type that
+ // may be shared across clients, so mutating it in place could
silently disable
+ // validation on another client. Instead, the skip-cert callback
must be installed on
+ // an internal clone, leaving the caller's object untouched.
+ var userSsl = new
System.Net.Security.SslClientAuthenticationOptions
+ {
+ TargetHost = "example.com"
+ };
+ var settings = new ConnectionSettings
+ {
+ Ssl = userSsl,
+ SkipCertificateValidation = true
+ };
+
+ using var connection = new Connection(
+ TestUri, CreateMockSerializer(), settings);
+
+ // The caller's own options object must be left exactly as
supplied: its
+ // RemoteCertificateValidationCallback must remain null and its
other fields intact.
+ Assert.Equal("example.com", userSsl.TargetHost);
+ Assert.Null(userSsl.RemoteCertificateValidationCallback);
+ // settings.Ssl must still reference the very same object the
caller provided.
+ Assert.Same(userSsl, settings.Ssl);
+ }
+
+ [Fact]
+ public void
ShouldNotShareSkipCertCallbackAcrossClientsSharingSslOptions()
+ {
+ // Reusing one Ssl options object across two clients, only one of
which skips cert
+ // validation, must not leak the accept-all callback onto the
shared object (and thus
+ // onto the other client).
+ var sharedSsl = new
System.Net.Security.SslClientAuthenticationOptions
+ {
+ TargetHost = "example.com"
+ };
+
+ var skipSettings = new ConnectionSettings
+ {
+ Ssl = sharedSsl,
+ SkipCertificateValidation = true
+ };
+ var strictSettings = new ConnectionSettings
+ {
+ Ssl = sharedSsl,
+ SkipCertificateValidation = false
+ };
+
+ using var skipConnection = new Connection(TestUri,
CreateMockSerializer(), skipSettings);
+ using var strictConnection = new Connection(TestUri,
CreateMockSerializer(), strictSettings);
+
+ // The shared object must never have had the accept-all callback
written onto it.
+ Assert.Null(sharedSsl.RemoteCertificateValidationCallback);
+ }
+
+ [Fact]
+ public void ShouldConstructWithProxyAndMaxHeaderBytes()
+ {
+ var settings = new ConnectionSettings
+ {
+ Proxy = new System.Net.WebProxy("http://localhost:3128"),
+ MaxResponseHeaderBytes = 16384
+ };
+
+ // Should construct the handler without throwing.
+ using var connection = new Connection(
+ TestUri, CreateMockSerializer(), settings);
+ }
+
+ [Theory]
+ [InlineData(1, 1)] // a single byte still needs one whole kilobyte
+ [InlineData(1023, 1)] // just under 1 KB rounds up to 1
+ [InlineData(1024, 1)] // exactly 1 KB stays 1 (no spurious round-up)
+ [InlineData(1025, 2)] // one byte over a KB boundary rounds up to 2
+ [InlineData(8191, 8)] // just under 8 KB rounds up to 8
+ [InlineData(8192, 8)] // exactly 8 KB (the default) stays 8
+ [InlineData(8193, 9)] // one byte over rounds up to 9
+ [InlineData(16384, 16)] // exactly 16 KB stays 16
+ public void ShouldRoundMaxResponseHeaderBytesUpToKilobytes(int bytes,
int expectedKilobytes)
+ {
+ // SocketsHttpHandler.MaxResponseHeadersLength is expressed in
kilobytes while the
+ // public option is in bytes. The conversion must round UP so the
configured byte cap
+ // is never silently lowered (ceil(bytes / 1024)). This asserts
the rounding math the
+ // public Connection constructor applies to the handler.
+ Assert.Equal(expectedKilobytes,
Connection.MaxResponseHeaderBytesToKilobytes(bytes));
+ }
+
+ [Fact]
+ public void ShouldLeaveMaxResponseHeadersAtDefaultWhenBytesUnset()
+ {
+ // When MaxResponseHeaderBytes is 0 (the default / unset), the
constructor must NOT
+ // touch the handler's MaxResponseHeadersLength, leaving the .NET
default in place.
+ // The conversion is only applied for a positive byte cap, so
constructing with the
+ // default must succeed without invoking the rounding path.
+ var settings = new ConnectionSettings();
+ Assert.Equal(0, settings.MaxResponseHeaderBytes);
+
+ // Should construct without throwing and without configuring the
header cap.
+ using var connection = new Connection(TestUri,
CreateMockSerializer(), settings);
+ }
+
private static RequestMessage CreateTestRequest()
{
return RequestMessage.Build("g.V()").AddG("g").Create();
@@ -940,6 +1062,162 @@ namespace Gremlin.Net.UnitTest.Driver
Assert.True(handler.WasCalled, "SendAsync should have been
called");
}
+ [Fact]
+ public async Task ShouldFillBatchSizeFromDefaultWhenUnset()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings { BatchSize = 42 };
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ var request = CreateTestRequest();
+ await connection.SubmitAsync<object>(request);
+
+ // The caller-owned request must NOT be mutated by default-filling.
+ Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize));
+ // The outgoing wire payload must carry the connection-level
default.
+ Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler));
+ }
+
+ [Fact]
+ public async Task ShouldNotOverrideExplicitBatchSize()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings { BatchSize = 42 };
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ var request =
RequestMessage.Build("g.V()").AddG("g").AddBatchSize(100).Create();
+ await connection.SubmitAsync<object>(request);
+
+ // A per-request explicit batchSize always wins, on the caller
object and the wire.
+ Assert.Equal(100, request.Fields[Tokens.ArgsBatchSize]);
+ Assert.Equal(100, await ReadBatchSizeFromBodyAsync(handler));
+ }
+
+ [Fact]
+ public async Task ShouldUseDefaultBatchSizeOf64ByDefault()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings();
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ var request = CreateTestRequest();
+ await connection.SubmitAsync<object>(request);
+
+ Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize));
+ Assert.Equal(64, await ReadBatchSizeFromBodyAsync(handler));
+ }
+
+ [Fact]
+ public async Task ShouldNotPersistDefaultBatchSizeAcrossResubmissions()
+ {
+ var (httpClient, handler) = CreateMockHttpClient();
+ var serializer = CreateMockSerializer();
+ var settings = new ConnectionSettings { BatchSize = 42 };
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ // Resubmitting the same message must not carry over a previously
injected default.
+ var request = CreateTestRequest();
+ await connection.SubmitAsync<object>(request);
+ await connection.SubmitAsync<object>(request);
+
+ Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize));
+ Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler));
+ }
+
+ /// <summary>
+ /// Reads the serialized JSON request body captured by the mock
handler and returns
+ /// the <c>batchSize</c> field value, or <c>null</c> when it is
absent.
+ /// </summary>
+ private static async Task<int?> ReadBatchSizeFromBodyAsync(MockHandler
handler)
+ {
+ Assert.NotNull(handler.CapturedRequest);
+ var bodyBytes = await
handler.CapturedRequest!.Content!.ReadAsByteArrayAsync();
+ using var doc = System.Text.Json.JsonDocument.Parse(bodyBytes);
+ if (doc.RootElement.TryGetProperty(Tokens.ArgsBatchSize, out var
batchSizeProp))
+ {
+ return batchSizeProp.GetInt32();
+ }
+ return null;
+ }
+
+ [Fact]
+ public async Task ShouldTimeOutSlowReadWhenReadTimeoutSet()
+ {
+ // A response stream that blocks indefinitely on read should
trigger the
+ // per-read idle timeout once it is consumed during
deserialization.
+ var blockingStream = new BlockingStream();
+ var handler = new StreamMockHandler(blockingStream);
+ var httpClient = new HttpClient(handler);
+ // The serializer actually reads from the stream so the read
timeout can fire.
+ var serializer = CreateReadingSerializer();
+ var settings = new ConnectionSettings
+ {
+ ReadTimeout = TimeSpan.FromMilliseconds(100)
+ };
+ using var connection = new Connection(TestUri, serializer,
settings, httpClient);
+
+ var result = await
connection.SubmitAsync<object>(CreateTestRequest());
+
+ // The background streaming task surfaces the timeout as a faulted
enumeration.
+ await Assert.ThrowsAnyAsync<Exception>(async () =>
+ {
+ await foreach (var _ in result) { }
+ });
+ }
+
+ private static IMessageSerializer CreateReadingSerializer(
+ string mimeType = SerializationTokens.GraphBinary4MimeType)
+ {
+ var serializer = Substitute.For<IMessageSerializer>();
+ serializer.MimeType.Returns(mimeType);
+ serializer.DeserializeMessageAsync(Arg.Any<Stream>(),
Arg.Any<CancellationToken>())
+ .Returns(callInfo => ReadAllAsync((Stream)callInfo[0],
(CancellationToken)callInfo[1]));
+ return serializer;
+ }
+
+ private static async IAsyncEnumerable<object> ReadAllAsync(Stream
stream,
+ [System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken cancellationToken)
+ {
+ var buffer = new byte[16];
+ while (await stream.ReadAsync(buffer,
cancellationToken).ConfigureAwait(false) > 0)
+ {
+ yield return new object();
+ }
+ }
+
+ /// <summary>
+ /// A stream whose ReadAsync never completes until cancelled, used
to exercise the
+ /// per-read timeout.
+ /// </summary>
+ private sealed class BlockingStream : Stream
+ {
+ public override async ValueTask<int> ReadAsync(Memory<byte> buffer,
+ CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(Timeout.Infinite,
cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ Thread.Sleep(Timeout.Infinite);
+ return 0;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => 0; set { } }
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw
new NotSupportedException();
+ public override void SetLength(long value) => throw new
NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count)
=> throw new NotSupportedException();
+ }
+
/// <summary>
/// A test HttpMessageHandler that captures the request and
returns a canned response.
/// The response uses ByteArrayContent but does NOT dispose the
content stream
diff --git
a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs
index 58442eb7d1..83518bc3b4 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs
@@ -51,7 +51,7 @@ namespace Gremlin.Net.UnitTest.Driver
{
var settings = new ConnectionSettings
{
- EnableCompression = true,
+ Compression = Compression.Deflate,
BulkResults = true
};
using var client = new GremlinClient(new GremlinServer(),
connectionSettings: settings);
diff --git
a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs
index add13d169f..35e6113b80 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs
@@ -21,6 +21,7 @@
#endregion
+using System;
using Gremlin.Net.Driver;
using Xunit;
@@ -80,5 +81,140 @@ namespace Gremlin.Net.UnitTest.Driver
Assert.Equal("/gremlin", gremlinServer.Uri.AbsolutePath);
}
+
+ [Fact]
+ public void ShouldBuildFromHttpUrl()
+ {
+ var gremlinServer =
GremlinServer.FromUrl("http://example.com:8182/gremlin");
+
+ var uri = gremlinServer.Uri;
+
+ Assert.Equal("http", uri.Scheme);
+ Assert.Equal("example.com", uri.Host);
+ Assert.Equal(8182, uri.Port);
+ Assert.Equal("/gremlin", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldBuildFromHttpsUrl()
+ {
+ var gremlinServer =
GremlinServer.FromUrl("https://example.com:8182/gremlin");
+
+ var uri = gremlinServer.Uri;
+
+ Assert.Equal("https", uri.Scheme);
+ Assert.Equal("example.com", uri.Host);
+ Assert.Equal(8182, uri.Port);
+ Assert.Equal("/gremlin", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldBuildFromUrlWithCustomPath()
+ {
+ var gremlinServer =
GremlinServer.FromUrl("https://example.com:8182/custom/path");
+
+ var uri = gremlinServer.Uri;
+
+ Assert.Equal("/custom/path", uri.AbsolutePath);
+ Assert.Equal("https://example.com:8182/custom/path",
uri.AbsoluteUri);
+ }
+
+ [Theory]
+ [InlineData("http://example.com:8182/gremlin", "example.com", 8182)]
+ [InlineData("https://1.2.3.4:5678/gremlin", "1.2.3.4", 5678)]
+ public void ShouldExtractHostAndPortFromUrl(string url, string
expectedHost, int expectedPort)
+ {
+ var gremlinServer = GremlinServer.FromUrl(url);
+
+ Assert.Equal(expectedHost, gremlinServer.Uri.Host);
+ Assert.Equal(expectedPort, gremlinServer.Uri.Port);
+ }
+
+ [Fact]
+ public void ShouldParseSecureUrlWithExplicitPortAndPath()
+ {
+ var uri = GremlinServer.FromUrl("https://h:1234/p").Uri;
+
+ Assert.Equal("https", uri.Scheme);
+ Assert.Equal("h", uri.Host);
+ Assert.Equal(1234, uri.Port);
+ Assert.Equal("/p", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldParseInsecureUrlWithExplicitPortAndPath()
+ {
+ var uri = GremlinServer.FromUrl("http://h:1234/p").Uri;
+
+ Assert.Equal("http", uri.Scheme);
+ Assert.Equal("h", uri.Host);
+ Assert.Equal(1234, uri.Port);
+ Assert.Equal("/p", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldDefaultPortWhenUrlOmitsPort()
+ {
+ var uri = GremlinServer.FromUrl("https://host/gremlin").Uri;
+
+ Assert.Equal("https", uri.Scheme);
+ Assert.Equal("host", uri.Host);
+ Assert.Equal(8182, uri.Port);
+ Assert.Equal("/gremlin", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldDefaultPathWhenUrlOmitsPath()
+ {
+ var uri = GremlinServer.FromUrl("https://host:8182").Uri;
+
+ Assert.Equal("https", uri.Scheme);
+ Assert.Equal("host", uri.Host);
+ Assert.Equal(8182, uri.Port);
+ Assert.Equal("/gremlin", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldDefaultPortAndPathWhenUrlOmitsBoth()
+ {
+ var uri = GremlinServer.FromUrl("https://host").Uri;
+
+ Assert.Equal("https", uri.Scheme);
+ Assert.Equal("host", uri.Host);
+ Assert.Equal(8182, uri.Port);
+ Assert.Equal("/gremlin", uri.AbsolutePath);
+ }
+
+ [Fact]
+ public void ShouldRejectUnsupportedSchemeForUrl()
+ {
+ Assert.Throws<ArgumentException>(() =>
GremlinServer.FromUrl("ws://example.com:8182/gremlin"));
+ }
+
+ [Fact]
+ public void ShouldRejectNonAbsoluteUrl()
+ {
+ Assert.Throws<ArgumentException>(() =>
GremlinServer.FromUrl("example.com:8182/gremlin"));
+ }
+
+ [Fact]
+ public void ShouldRejectNullUrl()
+ {
+ Assert.Throws<ArgumentNullException>(() =>
GremlinServer.FromUrl(null!));
+ }
+
+ [Fact]
+ public void ShouldBuildFromUri()
+ {
+ var gremlinServer = new GremlinServer(new
Uri("https://example.com:8182/gremlin"));
+
+ Assert.Equal("https://example.com:8182/gremlin",
gremlinServer.Uri.AbsoluteUri);
+ }
+
+ [Fact]
+ public void ShouldRejectUriWithUnsupportedScheme()
+ {
+ Assert.Throws<ArgumentException>(() => new GremlinServer(new
Uri("ftp://example.com:8182/gremlin")));
+ }
}
}