blob: 7e16bffea9af926b0cc6ef45c13153fcb5231af3 [file]
#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.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Gremlin.Net.Driver.Messages;
using Gremlin.Net.Process;
namespace Gremlin.Net.Driver
{
/// <summary>
/// HTTP-based connection that sends requests via HTTP POST to Gremlin Server.
/// </summary>
internal class Connection : IDisposable
{
private readonly HttpClient _httpClient;
private readonly Uri _uri;
private readonly IMessageSerializer _responseSerializer;
private readonly ConnectionSettings _settings;
private readonly IReadOnlyList<Func<HttpRequestContext, Task>> _interceptors;
/// <summary>
/// Creates a new HTTP connection. The <see cref="HttpClient"/> is backed by
/// SocketsHttpHandler which manages its own TCP connection pool internally,
/// so a single <see cref="Connection"/> instance handles concurrent requests efficiently.
/// </summary>
/// <param name="uri">The Gremlin Server URI.</param>
/// <param name="responseSerializer">The serializer for incoming responses (always required).</param>
/// <param name="settings">Connection settings.</param>
/// <param name="interceptors">Optional request interceptors.</param>
public Connection(Uri uri,
IMessageSerializer responseSerializer,
ConnectionSettings settings,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_responseSerializer = responseSerializer;
_settings = settings;
_interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
var handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = settings.IdleTimeout,
MaxConnectionsPerServer = settings.MaxConnections,
ConnectTimeout = settings.ConnectTimeout,
};
// 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)
{
System.Net.Security.SslClientAuthenticationOptions sslOptions;
if (settings.SkipCertificateValidation)
{
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);
}
/// <summary>
/// Constructor that accepts a pre-configured HttpClient (for testing).
/// </summary>
internal Connection(Uri uri,
IMessageSerializer responseSerializer,
ConnectionSettings settings, HttpClient httpClient,
IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
{
_uri = uri;
_responseSerializer = responseSerializer;
_settings = settings;
_httpClient = httpClient;
_interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
}
/// <summary>
/// Submits a <see cref="RequestMessage"/> to the server and returns a streaming
/// <see cref="ResultSet{T}"/> whose background task owns the HTTP response lifetime.
/// </summary>
/// <typeparam name="T">The type of the expected result elements.</typeparam>
/// <param name="requestMessage">The request to send.</param>
/// <param name="cancellationToken">The token to cancel the operation.</param>
/// <returns>A <see cref="ResultSet{T}"/> that streams results as they arrive.</returns>
public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
CancellationToken cancellationToken = default)
{
var headers = new Dictionary<string, string>();
headers["Accept"] = _responseSerializer.MimeType;
// 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";
}
if (_settings.EnableUserAgentOnConnect)
{
headers["User-Agent"] = Utils.UserAgent;
}
if (_settings.BulkResults)
{
headers["bulkResults"] = "true";
}
// 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 (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, outgoingMessage);
foreach (var interceptor in _interceptors)
{
await interceptor(context).ConfigureAwait(false);
}
// Auto-serialize after interceptors: idempotent if already serialized by an interceptor.
// Skip if body is HttpContent (an escape hatch for full wire-format control).
if (context.Body is not System.Net.Http.HttpContent)
{
context.SerializeBody();
}
// The HttpResponseMessage is NOT disposed here — ownership transfers to
// StreamingResponseContext via the background task.
HttpResponseMessage response;
using (var httpRequest = new HttpRequestMessage(new HttpMethod(context.Method), context.Uri))
{
if (context.Body is byte[] bodyBytes)
{
httpRequest.Content = new ByteArrayContent(bodyBytes);
}
else if (context.Body is HttpContent httpContent)
{
httpRequest.Content = httpContent;
}
else
{
throw new InvalidOperationException(
"Request body must be byte[] or HttpContent after serialization, " +
"but found " + (context.Body?.GetType().Name ?? "null") + ".");
}
foreach (var header in context.Headers)
{
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
{
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(header.Value);
}
else if (string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
{
// Content-Length is set automatically by ByteArrayContent; skip to avoid conflict.
}
else
{
httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
response = await _httpClient.SendAsync(httpRequest,
HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
}
if (!response.IsSuccessStatusCode &&
response.Content.Headers.ContentType?.MediaType != _responseSerializer.MimeType)
{
using (response)
{
var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
// Try to extract the "message" field from a JSON error response
var errorMessage = TryExtractJsonError(errorBody)
?? $"Gremlin Server returned HTTP {(int)response.StatusCode}: {errorBody}";
throw new HttpRequestException(errorMessage);
}
}
StreamingResponseContext? streamingContext = null;
CancellationTokenSource? disposeCts = null;
CancellationTokenSource? linkedCts = null;
try
{
var contentStream = await response.Content.ReadAsStreamAsync()
.ConfigureAwait(false);
// 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"))
{
decompressionStream = new ZLibStream(contentStream, CompressionMode.Decompress);
}
streamingContext = new StreamingResponseContext(
response, contentStream, decompressionStream);
var resultStream = _responseSerializer.DeserializeMessageAsync(
streamingContext.Stream, cancellationToken);
var channel = Channel.CreateUnbounded<object>(
new UnboundedChannelOptions { SingleWriter = true });
disposeCts = new CancellationTokenSource();
linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, disposeCts.Token);
var capturedLinkedCts = linkedCts;
var capturedStreamingContext = streamingContext;
var backgroundTask = Task.Run(async () =>
{
// Note: ResponseException from the status footer is propagated after
// all result items have been yielded, so consumers will see all results
// before the exception if the status code is non-200.
try
{
await foreach (var item in resultStream
.WithCancellation(capturedLinkedCts.Token).ConfigureAwait(false))
{
await channel.Writer.WriteAsync(item, capturedLinkedCts.Token)
.ConfigureAwait(false);
}
channel.Writer.Complete();
}
catch (Exception ex)
{
channel.Writer.Complete(ex);
}
finally
{
capturedLinkedCts.Dispose();
capturedStreamingContext.Dispose();
}
}, CancellationToken.None);
// Ownership transferred to background task — prevent catch block from
// double-disposing.
linkedCts = null;
streamingContext = null;
return new ResultSet<T>(channel.Reader, disposeCts, backgroundTask);
}
catch
{
linkedCts?.Dispose();
// If streamingContext was not yet created, the response is not yet owned
// by it and must be disposed separately.
if (streamingContext == null)
{
response.Dispose();
}
else
{
streamingContext.Dispose();
}
disposeCts?.Dispose();
throw;
}
}
/// <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
/// even when it cannot produce a GraphBinary response.
/// </summary>
private static string? TryExtractJsonError(string body)
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var messageProp))
{
return messageProp.GetString();
}
}
catch
{
// Not valid JSON — fall through to raw body
}
return null;
}
#region IDisposable Support
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
#endregion
internal IReadOnlyList<Func<HttpRequestContext, Task>> Interceptors => _interceptors;
}
}