Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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
Expand Down
32 changes: 30 additions & 2 deletions src/Qdrant.Client/Grpc/QdrantChannel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
#if NETFRAMEWORK
using System.Net.Http;
#endif
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
39 changes: 39 additions & 0 deletions src/Qdrant.Client/Grpc/UserAgentHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;

namespace Qdrant.Client.Grpc;

/// <summary>
/// Appends a <c>User-Agent</c> token to the request so
/// server-side tooling can attribute traffic to this client.
/// </summary>
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<HttpResponseMessage> 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<AssemblyInformationalVersionAttribute>()?.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);
}
}
78 changes: 78 additions & 0 deletions tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Request = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
}
Loading