diff --git a/README.md b/README.md index d5b96eb..4fe5a01 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,21 @@ var client = new QdrantClient(grpcClient); > var client = new QdrantClient(grpcClient); > ``` +#### User-Agent + +When a channel is created via `QdrantChannel.ForAddress` (which both +`QdrantClient("localhost")` and the `ClientConfiguration` overloads use), the +client adds a `qdrant-dotnet/` token to the request `User-Agent` +alongside the gRPC library token, e.g.: + +``` +grpc-dotnet/2.71.0 (.NET 8.0.4; CLR 8.0.4; net8.0; linux; x64) qdrant-dotnet/1.18.0 +``` + +This lets server-side tooling attribute traffic to the .NET client and its +version. On .NET Framework, where the underlying channel handler must be +configured manually, the token is not added automatically. + ### Working with collections Once a client has been created, create a new collection diff --git a/src/Qdrant.Client/Grpc/QdrantChannel.cs b/src/Qdrant.Client/Grpc/QdrantChannel.cs index b089840..99772a7 100644 --- a/src/Qdrant.Client/Grpc/QdrantChannel.cs +++ b/src/Qdrant.Client/Grpc/QdrantChannel.cs @@ -1,6 +1,4 @@ -#if NETFRAMEWORK using System.Net.Http; -#endif using Grpc.Core; using Grpc.Core.Interceptors; using Grpc.Net.Client; @@ -84,6 +82,11 @@ public static QdrantChannel ForAddress(string address, ClientConfiguration confi public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration configuration) { var channelOptions = new GrpcChannelOptions(); + +#if NETFRAMEWORK + // .NET Framework has finicky HTTP/2 support, so preserve the original + // behavior: only set an HttpClientHandler when certificate validation is + // required and otherwise let Grpc.Net.Client pick its default handler. if (configuration.CertificateThumbprint is not null) { channelOptions.HttpHandler = new HttpClientHandler @@ -92,6 +95,31 @@ public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration c CertificateValidation.Thumbprint(configuration.CertificateThumbprint) }; } +#else + HttpMessageHandler primaryHandler; + if (configuration.CertificateThumbprint is not null) + { + // Thumbprint validation requires HttpClientHandler's callback shape. + primaryHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + CertificateValidation.Thumbprint(configuration.CertificateThumbprint) + }; + } + else + { +#if NET6_0_OR_GREATER + // Match Grpc.Net.Client's default handler; HttpClientHandler would drop + // EnableMultipleHttp2Connections and cap throughput under concurrency. + primaryHandler = new SocketsHttpHandler { EnableMultipleHttp2Connections = true }; +#else + primaryHandler = new HttpClientHandler(); +#endif + } + + channelOptions.HttpHandler = new UserAgentHandler { InnerHandler = primaryHandler }; +#endif + var channel = GrpcChannel.ForAddress(address, channelOptions); return new QdrantChannel(channel, configuration); } diff --git a/src/Qdrant.Client/Grpc/UserAgentHandler.cs b/src/Qdrant.Client/Grpc/UserAgentHandler.cs new file mode 100644 index 0000000..b6163eb --- /dev/null +++ b/src/Qdrant.Client/Grpc/UserAgentHandler.cs @@ -0,0 +1,39 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; + +namespace Qdrant.Client.Grpc; + +/// +/// Appends a User-Agent token to the request so +/// server-side tooling can attribute traffic to this client. +/// +internal sealed class UserAgentHandler : DelegatingHandler +{ + internal const string ProductName = "qdrant-dotnet"; + internal static readonly string ProductVersion = ResolveVersion(); + + private static readonly ProductInfoHeaderValue Token = new(ProductName, ProductVersion); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Append, not replace, to keep grpc-dotnet's token. + // The guard avoids duplicates when a request is re-sent on retry. + if (!request.Headers.UserAgent.Contains(Token)) + request.Headers.UserAgent.Add(Token); + + return base.SendAsync(request, cancellationToken); + } + + private static string ResolveVersion() + { + var version = typeof(UserAgentHandler).Assembly + .GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; + + // Strip build metadata: "1.2.3+abc123" -> "1.2.3". + var plus = version.IndexOf('+'); + return plus < 0 ? version : version.Substring(0, plus); + } +} diff --git a/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs b/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs new file mode 100644 index 0000000..fb49733 --- /dev/null +++ b/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using FluentAssertions; +using Qdrant.Client.Grpc; +using Xunit; + +namespace Qdrant.Client; + +public class UserAgentHandlerTests +{ + [Fact] + public async Task AddsQdrantUserAgentToken() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + await handler.SendAsync(request, CancellationToken.None); + + capture.Request.Should().NotBeNull(); + capture.Request!.Headers.UserAgent + .Should().ContainSingle(p => + p.Product != null + && p.Product.Name == UserAgentHandler.ProductName + && p.Product.Version == UserAgentHandler.ProductVersion); + } + + [Fact] + public async Task PreservesExistingUserAgentTokens() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + // Mimic the token that Grpc.Net.Client injects. + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("grpc-dotnet", "2.71.0")); + + await handler.SendAsync(request, CancellationToken.None); + + var userAgents = capture.Request!.Headers.UserAgent; + userAgents.Should().Contain(p => p.Product != null && p.Product.Name == "grpc-dotnet"); + userAgents.Should().Contain(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName); + } + + [Fact] + public async Task DoesNotDuplicateTokenOnRetry() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + await handler.SendAsync(request, CancellationToken.None); + // Re-send the same request instance (as can happen on a retry). + await handler.SendAsync(request, CancellationToken.None); + + capture.Request!.Headers.UserAgent + .Count(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName) + .Should().Be(1); + } + + private static (HttpMessageInvoker invoker, RequestCapturingHandler capture) CreateInvoker() + { + var capture = new RequestCapturingHandler(); + var handler = new UserAgentHandler { InnerHandler = capture }; + return (new HttpMessageInvoker(handler), capture); + } + + private sealed class RequestCapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } +}