From 126f042eb7eeccd314f5eee7e047f01a2308ca72 Mon Sep 17 00:00:00 2001 From: "PUBNUB\\jakub.grzesiowski" Date: Wed, 3 Jun 2026 17:32:21 +0200 Subject: [PATCH 1/2] Implement http/2 functionality --- src/Api/PubnubApi/PNConfiguration.cs | 13 ++ src/Api/PubnubApi/Pubnub.cs | 2 +- src/Api/PubnubApi/PubnubApi.csproj | 2 +- .../PubnubApi/Transport/HttpClientService.cs | 71 +++++- src/Api/PubnubApi/Transport/Middleware.cs | 1 + .../TransportContract/TransportRequest.cs | 1 + .../TransportContract/TransportResponse.cs | 6 + .../PubnubApi.Tests/WhenHttp2IsConfigured.cs | 202 ++++++++++++++++++ .../PubnubApi.Tests/WhenHttp2OriginIsUsed.cs | 190 ++++++++++++++++ .../PubnubApiPCL.Tests.csproj | 6 + 10 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 src/UnitTests/PubnubApi.Tests/WhenHttp2IsConfigured.cs create mode 100644 src/UnitTests/PubnubApi.Tests/WhenHttp2OriginIsUsed.cs diff --git a/src/Api/PubnubApi/PNConfiguration.cs b/src/Api/PubnubApi/PNConfiguration.cs index cb7078bdb..a84ffe983 100644 --- a/src/Api/PubnubApi/PNConfiguration.cs +++ b/src/Api/PubnubApi/PNConfiguration.cs @@ -240,6 +240,18 @@ public PNReconnectionPolicy ReconnectionPolicy { public int FileMessagePublishRetryLimit { get; set; } + /// + /// When enabled (default), each outbound request negotiates HTTP/2 using + /// / VersionPolicy, with + /// automatic fallback to HTTP/1.1. HTTP/2 only takes effect against an HTTP/2-capable + /// (customer-enabled) PubNub origin; otherwise requests transparently use HTTP/1.1. + /// The negotiated protocol is re-evaluated on new connections (natural reconnect or + /// Pubnub client recreation); the SDK does not pin or proactively probe for HTTP/2. + /// On target frameworks without VersionPolicy support (netstandard2.0/.NET Framework), + /// only the request version is set as a best-effort hint. + /// + public bool EnableHttp2 { get; set; } = true; + [Obsolete("PNConfiguration(string uuid) is deprecated, please use PNConfiguration(UserId userId) instead.")] public PNConfiguration(string uuid) { @@ -286,6 +298,7 @@ private void ConstructorInit(UserId currentUserId) userId = currentUserId; LogLevel = PubnubLogLevel.None; EnableEventEngine = true; + EnableHttp2 = true; } private void setDefaultRetryConfigurationFromPolicy(PNReconnectionPolicy policy) diff --git a/src/Api/PubnubApi/Pubnub.cs b/src/Api/PubnubApi/Pubnub.cs index 2fee9d258..d169eaaec 100644 --- a/src/Api/PubnubApi/Pubnub.cs +++ b/src/Api/PubnubApi/Pubnub.cs @@ -1262,7 +1262,7 @@ public Pubnub(PNConfiguration config, IHttpClientService httpTransportService = //Defaulting to DotNet PNSDK source if no custom one is specified Version = (ipnsdkSource == default) ? new DotNetPNSDKSource().GetPNSDK() : ipnsdkSource.GetPNSDK(); IHttpClientService httpClientService = - httpTransportService ?? new HttpClientService(proxy: config.Proxy); + httpTransportService ?? new HttpClientService(proxy: config.Proxy, enableHttp2: config.EnableHttp2); httpClientService.SetLogger(logger); transportMiddleware = middleware ?? new Middleware(httpClientService, config, this, tokenManager); logger?.Debug(GetConfigurationLogString(config)); diff --git a/src/Api/PubnubApi/PubnubApi.csproj b/src/Api/PubnubApi/PubnubApi.csproj index 72c248d78..db2d58cb2 100644 --- a/src/Api/PubnubApi/PubnubApi.csproj +++ b/src/Api/PubnubApi/PubnubApi.csproj @@ -1,7 +1,7 @@  - net60 + net6.0 latest True pubnub.snk diff --git a/src/Api/PubnubApi/Transport/HttpClientService.cs b/src/Api/PubnubApi/Transport/HttpClientService.cs index c92e6c28a..6e73b778d 100644 --- a/src/Api/PubnubApi/Transport/HttpClientService.cs +++ b/src/Api/PubnubApi/Transport/HttpClientService.cs @@ -11,10 +11,16 @@ namespace PubnubApi public class HttpClientService : IHttpClientService { private readonly HttpClient httpClient; + private readonly bool enableHttp2; private PubnubLogModule logger; - public HttpClientService(IWebProxy proxy) + public HttpClientService(IWebProxy proxy) : this(proxy, true) { + } + + public HttpClientService(IWebProxy proxy, bool enableHttp2) + { + this.enableHttp2 = enableHttp2; httpClient = new HttpClient() { Timeout = Timeout.InfiniteTimeSpan @@ -28,6 +34,39 @@ public HttpClientService(IWebProxy proxy) httpClient.Timeout = Timeout.InfiniteTimeSpan; } + /// + /// Creates a transport backed by a caller-supplied . + /// Intended for testing and advanced transport scenarios (e.g. observing outgoing + /// requests or stubbing responses). The supplied handler is fully responsible for + /// TLS/certificate validation and proxy behavior; the SDK does not weaken or override + /// them here. Do not pass a handler that disables certificate validation in production. + /// + /// The message handler used to send requests. + /// Whether outbound requests should request HTTP/2 with HTTP/1.1 fallback. + public HttpClientService(HttpMessageHandler handler, bool enableHttp2) + { + this.enableHttp2 = enableHttp2; + httpClient = new HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan + }; + } + + // HTTP/2 is requested by default; RequestVersionOrLower guarantees HTTP/1.1 fallback + // against non-HTTP/2 origins. Set per HttpRequestMessage because the SDK uses explicit + // request messages over a shared HttpClient. + private void ConfigureHttpVersion(HttpRequestMessage requestMessage) + { + if (!enableHttp2) + { + return; + } + requestMessage.Version = new Version(2, 0); +#if NET6_0_OR_GREATER || NET60 + requestMessage.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; +#endif + } + public void SetLogger(PubnubLogModule logger) { this.logger = logger; @@ -41,6 +80,7 @@ public async Task GetRequest(TransportRequest transportReques { HttpRequestMessage requestMessage = new HttpRequestMessage(method: HttpMethod.Get, requestUri: transportRequest.RequestUrl); + ConfigureHttpVersion(requestMessage); if (transportRequest.Headers.Keys.Count > 0) { foreach (var kvp in transportRequest.Headers) @@ -68,8 +108,11 @@ public async Task GetRequest(TransportRequest transportReques StatusCode = (int)httpResult.StatusCode, Content = responseContent, Headers = httpResult.Headers.ToDictionary(h => h.Key, h => h.Value), - RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath + RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath, + NegotiatedProtocolVersion = httpResult.Version }; + logger?.Debug( + $"PubNub request completed: operation={transportRequest.OperationType} protocol=HTTP/{httpResult.Version}"); logger?.Debug( $"HttpClient Service: Received http response from server with status code {httpResult.StatusCode}, content-length: {transportResponse.Content.Length} bytes, for url \n{transportRequest.RequestUrl}"); } @@ -118,6 +161,7 @@ public async Task PostRequest(TransportRequest transportReque HttpRequestMessage requestMessage = new HttpRequestMessage(method: HttpMethod.Post, requestUri: transportRequest.RequestUrl) { Content = postData }; + ConfigureHttpVersion(requestMessage); // Set Http Request header, When the header is not a payload content header. if (transportRequest.Headers.Keys.Count > 0 && transportRequest.BodyContentBytes == null) { @@ -145,8 +189,11 @@ public async Task PostRequest(TransportRequest transportReque StatusCode = (int)httpResult.StatusCode, Content = responseContent, Headers = httpResult.Headers.ToDictionary(h => h.Key, h => h.Value), - RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath + RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath, + NegotiatedProtocolVersion = httpResult.Version }; + logger?.Debug( + $"PubNub request completed: operation={transportRequest.OperationType} protocol=HTTP/{httpResult.Version}"); logger?.Debug( $"Received http response from server with status code {httpResult.StatusCode}, content-length: {transportResponse.Content.Length} bytes, for url {transportRequest.RequestUrl}"); } @@ -197,6 +244,7 @@ public async Task PutRequest(TransportRequest transportReques HttpRequestMessage requestMessage = new HttpRequestMessage(method: HttpMethod.Put, requestUri: transportRequest.RequestUrl) { Content = putData }; + ConfigureHttpVersion(requestMessage); if (transportRequest.Headers.Keys.Count > 0) { foreach (var kvp in transportRequest.Headers) @@ -224,8 +272,11 @@ public async Task PutRequest(TransportRequest transportReques StatusCode = (int)httpResult.StatusCode, Content = responseContent, Headers = httpResult.Headers.ToDictionary(h => h.Key, h => h.Value), - RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath + RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath, + NegotiatedProtocolVersion = httpResult.Version }; + logger?.Debug( + $"PubNub request completed: operation={transportRequest.OperationType} protocol=HTTP/{httpResult.Version}"); logger?.Debug( $"Received http response from server with status code {httpResult.StatusCode}, content-length: {transportResponse.Content.Length} bytes, for url {transportRequest.RequestUrl}"); } @@ -260,6 +311,7 @@ public async Task DeleteRequest(TransportRequest transportReq { HttpRequestMessage requestMessage = new HttpRequestMessage(method: HttpMethod.Delete, requestUri: transportRequest.RequestUrl); + ConfigureHttpVersion(requestMessage); if (transportRequest.Headers.Keys.Count > 0) { foreach (var kvp in transportRequest.Headers) @@ -287,8 +339,11 @@ public async Task DeleteRequest(TransportRequest transportReq StatusCode = (int)httpResult.StatusCode, Content = responseContent, Headers = httpResult.Headers.ToDictionary(h => h.Key, h => h.Value), - RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath + RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath, + NegotiatedProtocolVersion = httpResult.Version }; + logger?.Debug( + $"PubNub request completed: operation={transportRequest.OperationType} protocol=HTTP/{httpResult.Version}"); logger?.Debug( $"Received http response from server with status code {httpResult.StatusCode}, content-length: {transportResponse.Content.Length} bytes, for url {transportRequest.RequestUrl}"); } @@ -340,6 +395,7 @@ public async Task PatchRequest(TransportRequest transportRequ HttpRequestMessage requestMessage = new HttpRequestMessage(new HttpMethod("PATCH"), requestUri: transportRequest.RequestUrl) { Content = patchData }; + ConfigureHttpVersion(requestMessage); if (transportRequest.Headers.Keys.Count > 0) { foreach (var kvp in transportRequest.Headers) @@ -367,8 +423,11 @@ public async Task PatchRequest(TransportRequest transportRequ StatusCode = (int)httpResult.StatusCode, Content = responseContent, Headers = httpResult.Headers.ToDictionary(h => h.Key, h => h.Value), - RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath + RequestUrl = httpResult.RequestMessage?.RequestUri?.AbsolutePath, + NegotiatedProtocolVersion = httpResult.Version }; + logger?.Debug( + $"PubNub request completed: operation={transportRequest.OperationType} protocol=HTTP/{httpResult.Version}"); logger?.Debug( $"Received http response from server with status code {httpResult.StatusCode}, content-length: {transportResponse.Content.Length} bytes, for url {transportRequest.RequestUrl}"); } diff --git a/src/Api/PubnubApi/Transport/Middleware.cs b/src/Api/PubnubApi/Transport/Middleware.cs index 60f42c813..40511a027 100644 --- a/src/Api/PubnubApi/Transport/Middleware.cs +++ b/src/Api/PubnubApi/Transport/Middleware.cs @@ -100,6 +100,7 @@ public TransportRequest PreapareTransportRequest(RequestParameter requestParamet var transportRequest = new TransportRequest() { RequestType = requestParameter.RequestType, + OperationType = operationType, RequestUrl = urlString, BodyContentString = requestParameter.BodyContentString, FormData = requestParameter.FormData, diff --git a/src/Api/PubnubApi/TransportContract/TransportRequest.cs b/src/Api/PubnubApi/TransportContract/TransportRequest.cs index b3e8642e2..45fe6ebcc 100644 --- a/src/Api/PubnubApi/TransportContract/TransportRequest.cs +++ b/src/Api/PubnubApi/TransportContract/TransportRequest.cs @@ -7,6 +7,7 @@ namespace PubnubApi public class TransportRequest { public string RequestType { get; set; } + public PNOperationType OperationType { get; set; } public Dictionary Headers { get; set; } = new Dictionary(); public string RequestUrl { get; set; } public byte[] FormData { get; set; } = default; diff --git a/src/Api/PubnubApi/TransportContract/TransportResponse.cs b/src/Api/PubnubApi/TransportContract/TransportResponse.cs index cae84a683..8c73e7104 100644 --- a/src/Api/PubnubApi/TransportContract/TransportResponse.cs +++ b/src/Api/PubnubApi/TransportContract/TransportResponse.cs @@ -9,6 +9,12 @@ public class TransportResponse public byte[] Content { get; set; } public Dictionary> Headers { get; set; } public string RequestUrl { get; set; } + + /// + /// The negotiated HTTP protocol version of the response (e.g. 2.0 or 1.1), + /// taken from HttpResponseMessage.Version. Null when no response was received. + /// + public Version NegotiatedProtocolVersion { get; set; } public Exception Error { get; set; } public bool IsTimeOut {get; set;} diff --git a/src/UnitTests/PubnubApi.Tests/WhenHttp2IsConfigured.cs b/src/UnitTests/PubnubApi.Tests/WhenHttp2IsConfigured.cs new file mode 100644 index 000000000..083b9e1f5 --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/WhenHttp2IsConfigured.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using PubnubApi; + +namespace PubNubMessaging.Tests +{ + /// + /// Offline tests for HTTP/2 request shaping, protocol/operation logging, HTTP/1.1 fallback, + /// and protocol re-negotiation. These inject a custom HttpMessageHandler into HttpClientService + /// so no real network I/O occurs. The custom MockServer is intentionally NOT used because it + /// cannot negotiate HTTP/2. + /// + [TestFixture] + public class WhenHttp2IsConfigured + { + /// + /// Records the last outgoing request and returns a response whose protocol version is + /// taken from a caller-supplied queue (falls back to the last value once drained). + /// + private class RecordingHandler : HttpMessageHandler + { + private readonly Queue responseVersions; + private Version lastResponseVersion = new Version(1, 1); + + public HttpRequestMessage LastRequest { get; private set; } + public Version LastRequestVersion { get; private set; } + public int CallCount { get; private set; } + + public RecordingHandler(params Version[] versions) + { + responseVersions = new Queue(versions ?? Array.Empty()); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + LastRequest = request; + LastRequestVersion = request.Version; + + if (responseVersions.Count > 0) + { + lastResponseVersion = responseVersions.Dequeue(); + } + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Version = lastResponseVersion, + Content = new StringContent("[]"), + RequestMessage = request + }; + return Task.FromResult(response); + } + } + + private class CapturingLogger : IPubnubLogger + { + public List Messages { get; } = new List(); + public void Trace(string logMessage) => Messages.Add(logMessage); + public void Debug(string logMessage) => Messages.Add(logMessage); + public void Info(string logMessage) => Messages.Add(logMessage); + public void Warn(string logMessage) => Messages.Add(logMessage); + public void Error(string logMessage) => Messages.Add(logMessage); + } + + private static TransportRequest CreateTransportRequest(string method, PNOperationType operationType) + { + return new TransportRequest + { + RequestType = method, + OperationType = operationType, + RequestUrl = "https://ps.pndsn.com/time/0", + BodyContentString = method is "POST" or "PUT" or "PATCH" ? "{}" : null, + CancellationTokenSource = new CancellationTokenSource() + }; + } + + private static Task Dispatch(HttpClientService service, TransportRequest request) + { + return request.RequestType switch + { + "POST" => service.PostRequest(request), + "PUT" => service.PutRequest(request), + "PATCH" => service.PatchRequest(request), + "DELETE" => service.DeleteRequest(request), + _ => service.GetRequest(request) + }; + } + + // A. Request shaping + [TestCase("GET", PNOperationType.PNSubscribeOperation)] + [TestCase("POST", PNOperationType.PNPublishOperation)] + [TestCase("PUT", PNOperationType.PNSetUuidMetadataOperation)] + [TestCase("PATCH", PNOperationType.PNSetUuidMetadataOperation)] + [TestCase("DELETE", PNOperationType.PNDeleteMessageOperation)] + public async Task WhenHttp2EnabledRequestUsesHttp2(string method, PNOperationType operationType) + { + var handler = new RecordingHandler(new Version(2, 0)); + var service = new HttpClientService(handler, enableHttp2: true); + + await Dispatch(service, CreateTransportRequest(method, operationType)); + + Assert.AreEqual(new Version(2, 0), handler.LastRequestVersion, $"{method} should request HTTP/2"); +#if NET6_0_OR_GREATER + Assert.AreEqual(HttpVersionPolicy.RequestVersionOrLower, handler.LastRequest.VersionPolicy, + $"{method} should allow HTTP/1.1 fallback"); +#endif + } + + [TestCase("GET")] + [TestCase("POST")] + [TestCase("PUT")] + [TestCase("PATCH")] + [TestCase("DELETE")] + public async Task WhenHttp2DisabledRequestUsesHttp11(string method) + { + var handler = new RecordingHandler(new Version(1, 1)); + var service = new HttpClientService(handler, enableHttp2: false); + + await Dispatch(service, CreateTransportRequest(method, PNOperationType.PNSubscribeOperation)); + + Assert.AreEqual(new Version(1, 1), handler.LastRequestVersion, $"{method} should remain HTTP/1.1 when disabled"); + } + + // B. Protocol + operation logging + [Test] + public async Task ThenLogsNegotiatedHttp2ProtocolAndOperation() + { + var handler = new RecordingHandler(new Version(2, 0)); + var service = new HttpClientService(handler, enableHttp2: true); + var logger = new CapturingLogger(); + service.SetLogger(new PubnubLogModule(PubnubLogLevel.Debug, new List { logger })); + + await Dispatch(service, CreateTransportRequest("GET", PNOperationType.PNSubscribeOperation)); + + Assert.IsTrue(logger.Messages.Exists(m => + m.Contains("operation=PNSubscribeOperation") && m.Contains("protocol=HTTP/2.0")), + "Expected a completion log with operation and HTTP/2.0 protocol"); + } + + [Test] + public async Task ThenLogsNegotiatedHttp11ProtocolOnFallback() + { + var handler = new RecordingHandler(new Version(1, 1)); + var service = new HttpClientService(handler, enableHttp2: true); + var logger = new CapturingLogger(); + service.SetLogger(new PubnubLogModule(PubnubLogLevel.Debug, new List { logger })); + + await Dispatch(service, CreateTransportRequest("POST", PNOperationType.PNPublishOperation)); + + Assert.IsTrue(logger.Messages.Exists(m => + m.Contains("operation=PNPublishOperation") && m.Contains("protocol=HTTP/1.1")), + "Expected a completion log with operation and HTTP/1.1 protocol"); + } + + // C. Fallback still succeeds + [Test] + public async Task WhenServerRespondsHttp11RequestStillSucceeds() + { + // Request asks for HTTP/2 but the (simulated) server answers with HTTP/1.1. + var handler = new RecordingHandler(new Version(1, 1)); + var service = new HttpClientService(handler, enableHttp2: true); + + var response = await Dispatch(service, CreateTransportRequest("GET", PNOperationType.PNTimeOperation)); + + Assert.AreEqual(new Version(2, 0), handler.LastRequestVersion, "Request should still attempt HTTP/2"); + Assert.IsNull(response.Error, "Request should succeed despite HTTP/1.1 downgrade"); + Assert.AreEqual(200, response.StatusCode); + Assert.AreEqual(new Version(1, 1), response.NegotiatedProtocolVersion, "Negotiated protocol should be HTTP/1.1"); + } + + // D. Re-negotiation / no pinning + [Test] + public async Task ThenProtocolCanChangeAcrossRequestsWithoutPinning() + { + // Simulates HTTP/2.0 -> reconnect/recreation -> HTTP/1.1 -> HTTP/2.0 + var handler = new RecordingHandler(new Version(2, 0), new Version(1, 1), new Version(2, 0)); + var service = new HttpClientService(handler, enableHttp2: true); + + var first = await Dispatch(service, CreateTransportRequest("GET", PNOperationType.PNSubscribeOperation)); + var second = await Dispatch(service, CreateTransportRequest("GET", PNOperationType.PNSubscribeOperation)); + var third = await Dispatch(service, CreateTransportRequest("GET", PNOperationType.PNSubscribeOperation)); + + Assert.AreEqual(new Version(2, 0), first.NegotiatedProtocolVersion); + Assert.AreEqual(new Version(1, 1), second.NegotiatedProtocolVersion); + Assert.AreEqual(new Version(2, 0), third.NegotiatedProtocolVersion); + Assert.AreEqual(3, handler.CallCount, "No extra probe requests should be issued"); + } + + // E. Config default + [Test] + public void ThenEnableHttp2DefaultsToTrue() + { + var config = new PNConfiguration(new UserId("http2-default-test")); + Assert.IsTrue(config.EnableHttp2, "EnableHttp2 should default to true"); + } + } +} diff --git a/src/UnitTests/PubnubApi.Tests/WhenHttp2OriginIsUsed.cs b/src/UnitTests/PubnubApi.Tests/WhenHttp2OriginIsUsed.cs new file mode 100644 index 000000000..dea96e85b --- /dev/null +++ b/src/UnitTests/PubnubApi.Tests/WhenHttp2OriginIsUsed.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using PubnubApi; + +namespace PubNubMessaging.Tests +{ + /// + /// Real-network integration tests that point the SDK at the HTTP/2-capable origin + /// "h2.pubnubapi.com" and verify HTTP/2 is actually negotiated. + /// + /// Observation is done with a pass-through wrapping a real + /// , injected via the public + /// HttpClientService(HttpMessageHandler, bool) constructor; each captured response is asserted + /// to have negotiated HTTP/2 (response.Version.Major == 2). + /// + [TestFixture] + public class WhenHttp2OriginIsUsed + { + private const string Http2Origin = "h2.pubnubapi.com"; + private const string RegularOrigin = "ps.pndsn.com"; + private static readonly int ConnectWaitTimeoutMs = 15 * 1000; + + private Pubnub pubnub; + + /// + /// Records each request/response protocol version while passing the call through to the + /// real network via the inner . + /// + private sealed class RecordingPassThroughHandler : DelegatingHandler + { + public RecordingPassThroughHandler() : base(new SocketsHttpHandler()) + { + } + + public List<(Uri Url, Version RequestVersion, Version ResponseVersion)> Calls { get; } = + new List<(Uri, Version, Version)>(); + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + lock (Calls) + { + Calls.Add((request.RequestUri, request.Version, response.Version)); + } + return response; + } + } + + private Pubnub CreateHttp2Pubnub(RecordingPassThroughHandler handler, string origin = Http2Origin) + { + var config = new PNConfiguration(new UserId("http2-itest-uuid")) + { + PublishKey = PubnubCommon.NonPAMPublishKey, + SubscribeKey = PubnubCommon.NONPAMSubscribeKey, + Origin = origin, + Secure = true, // HTTP/2 requires TLS/ALPN + EnableHttp2 = true, + LogLevel = PubnubLogLevel.Debug + }; + var http = new HttpClientService(handler, enableHttp2: true); + return new Pubnub(config, httpTransportService: http); + } + + private static void AssertNegotiatedHttp2(RecordingPassThroughHandler handler, string pathFragment) + { + List<(Uri Url, Version RequestVersion, Version ResponseVersion)> match; + lock (handler.Calls) + { + match = handler.Calls.FindAll(c => c.Url.AbsolutePath.Contains(pathFragment)); + } + + Assert.IsNotEmpty(match, $"No request captured for '{pathFragment}'"); + foreach (var c in match) + { + Assert.AreEqual(2, c.ResponseVersion.Major, + $"Expected HTTP/2 for {c.Url.AbsolutePath}, got HTTP/{c.ResponseVersion}"); + Assert.AreEqual(new Version(2, 0), c.RequestVersion, + $"Expected request to opt into HTTP/2 for {c.Url.AbsolutePath}, got HTTP/{c.RequestVersion}"); + } + } + + private static void AssertNegotiatedHttp11(RecordingPassThroughHandler handler, string pathFragment) + { + List<(Uri Url, Version RequestVersion, Version ResponseVersion)> match; + lock (handler.Calls) + { + match = handler.Calls.FindAll(c => c.Url.AbsolutePath.Contains(pathFragment)); + } + + Assert.IsNotEmpty(match, $"No request captured for '{pathFragment}'"); + foreach (var c in match) + { + // The request opts into HTTP/2 (RequestVersionOrLower), but a non-HTTP/2 origin + // must transparently fall back to HTTP/1.1. + Assert.AreEqual(1, c.ResponseVersion.Major, + $"Expected HTTP/1.1 fallback for {c.Url.AbsolutePath}, got HTTP/{c.ResponseVersion}"); + } + } + + [TearDown] + public void Exit() + { + if (pubnub != null) + { + pubnub.Destroy(); + pubnub = null; + } + } + + [Test] + public async Task ThenTimeRequestNegotiatesHttp2() + { + var handler = new RecordingPassThroughHandler(); + pubnub = CreateHttp2Pubnub(handler); + + var timeResult = await pubnub.Time().ExecuteAsync(); + + Assert.IsNotNull(timeResult, "Time() returned null"); + Assert.IsFalse(timeResult.Status.Error, "Time() request failed"); + AssertNegotiatedHttp2(handler, "/time"); + } + + [Test] + public async Task ThenPublishNegotiatesHttp2() + { + var handler = new RecordingPassThroughHandler(); + pubnub = CreateHttp2Pubnub(handler); + + var publishResult = await pubnub.Publish() + .Channel("http2_test") + .Message("hello-h2") + .ExecuteAsync(); + + Assert.IsNotNull(publishResult, "Publish() returned null"); + Assert.IsFalse(publishResult.Status.Error, "Publish() request failed"); + AssertNegotiatedHttp2(handler, "/publish"); + } + + [Test] + public async Task ThenNonHttp2OriginFallsBackToHttp11() + { + // EnableHttp2 is on, but the regular origin does not support HTTP/2; the request + // must still succeed by transparently falling back to HTTP/1.1. + var handler = new RecordingPassThroughHandler(); + pubnub = CreateHttp2Pubnub(handler, RegularOrigin); + + var timeResult = await pubnub.Time().ExecuteAsync(); + + Assert.IsNotNull(timeResult, "Time() returned null"); + Assert.IsFalse(timeResult.Status.Error, "Time() request failed against the regular origin"); + AssertNegotiatedHttp11(handler, "/time"); + } + + [Test] + public void ThenSubscribeLoopNegotiatesHttp2() + { + var handler = new RecordingPassThroughHandler(); + pubnub = CreateHttp2Pubnub(handler); + + string channel = "http2_subscribe_test"; + var connectedEvent = new ManualResetEvent(false); + + var listener = new SubscribeCallbackExt( + (_, _) => { }, + (_, _) => { }, + (_, status) => + { + if (status.StatusCode == 200 && status.Category == PNStatusCategory.PNConnectedCategory) + { + connectedEvent.Set(); + } + }); + pubnub.AddListener(listener); + + pubnub.Subscribe().Channels(new[] { channel }).Execute(); + + bool connected = connectedEvent.WaitOne(ConnectWaitTimeoutMs); + Assert.IsTrue(connected, "Subscribe did not reach connected status in time"); + + AssertNegotiatedHttp2(handler, "/subscribe"); + + pubnub.Unsubscribe().Channels(new[] { channel }).Execute(); + } + } +} diff --git a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj index a9dc4dfe8..416d6d060 100644 --- a/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj +++ b/src/UnitTests/PubnubApiPCL.Tests/PubnubApiPCL.Tests.csproj @@ -82,6 +82,12 @@ + + WhenHttp2IsConfigured.cs + + + WhenHttp2OriginIsUsed.cs + From c782977ccf1a108dc6ccd402072f88659e0cc763 Mon Sep 17 00:00:00 2001 From: PubNub Release Bot <120067856+pubnub-release-bot@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:11:02 +0000 Subject: [PATCH 2/2] PubNub SDK v8.3.0 release. --- .pubnub.yml | 19 ++++++++++++------- CHANGELOG | 4 ++++ src/Api/PubnubApi/Properties/AssemblyInfo.cs | 4 ++-- src/Api/PubnubApi/PubnubApi.csproj | 5 ++--- src/Api/PubnubApiPCL/PubnubApiPCL.csproj | 5 ++--- src/Api/PubnubApiUWP/PubnubApiUWP.csproj | 5 ++--- src/Api/PubnubApiUnity/PubnubApiUnity.csproj | 2 +- 7 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.pubnub.yml b/.pubnub.yml index 57435ca1f..9161aa905 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,8 +1,13 @@ name: c-sharp -version: "8.2.1" +version: "8.3.0" schema: 1 scm: github.com/pubnub/c-sharp changelog: + - date: 2026-06-08 + version: v8.3.0 + changes: + - type: feature + text: "Added a EnableHttp2 flag to the config which if set to true (the default value) will make all calls in the HttpClientService request Http/2 by default and fall back to Http/1.1 if not available on the origin." - date: 2026-05-29 version: v8.2.1 changes: @@ -989,14 +994,14 @@ features: - QUERY-PARAM supported-platforms: - - version: Pubnub 'C#' 8.2.1 + version: Pubnub 'C#' 8.3.0 platforms: - Windows 10 and up - Windows Server 2008 and up frameworks: - .Net Framework 4.5+ - - version: PubnubPCL 'C#' 8.2.1 + version: PubnubPCL 'C#' 8.3.0 platforms: - Xamarin.Android - Xamarin.iOS @@ -1008,7 +1013,7 @@ supported-platforms: frameworks: - .Net 4.5+ - - version: PubnubUWP 'C#' 8.2.1 + version: PubnubUWP 'C#' 8.3.0 platforms: - Windows Phone 10 - Universal Windows Apps @@ -1032,7 +1037,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: Pubnub - location: https://github.com/pubnub/c-sharp/releases/tag/v8.2.1 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.0 requires: - name: ".Net" @@ -1273,7 +1278,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubNubPCL - location: https://github.com/pubnub/c-sharp/releases/tag/v8.2.1 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.0 requires: - name: ".Net" @@ -1624,7 +1629,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubnubUWP - location: https://github.com/pubnub/c-sharp/releases/tag/v8.2.1 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.3.0 requires: - name: "Universal Windows Platform Development" diff --git a/CHANGELOG b/CHANGELOG index de51427fd..9194bbe88 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +v8.3.0 - June 08 2026 +----------------------------- +- Added: added a EnableHttp2 flag to the config which if set to true (the default value) will make all calls in the HttpClientService request Http/2 by default and fall back to Http/1.1 if not available on the origin. + v8.2.1 - May 29 2026 ----------------------------- - Fixed: fixes issue of hereNow returning incorrect Occupancy when Occupancy count value is omitted from the response of single channel hereNow call. diff --git a/src/Api/PubnubApi/Properties/AssemblyInfo.cs b/src/Api/PubnubApi/Properties/AssemblyInfo.cs index d4dde423d..de19cd0f4 100644 --- a/src/Api/PubnubApi/Properties/AssemblyInfo.cs +++ b/src/Api/PubnubApi/Properties/AssemblyInfo.cs @@ -11,8 +11,8 @@ [assembly: AssemblyProduct("Pubnub C# SDK")] [assembly: AssemblyCopyright("Copyright © 2021")] [assembly: AssemblyTrademark("")] -[assembly: AssemblyVersion("8.2.1")] -[assembly: AssemblyFileVersion("8.2.1")] +[assembly: AssemblyVersion("8.3.0")] +[assembly: AssemblyFileVersion("8.3.0")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. diff --git a/src/Api/PubnubApi/PubnubApi.csproj b/src/Api/PubnubApi/PubnubApi.csproj index 3b9b88345..7c44e8ea9 100644 --- a/src/Api/PubnubApi/PubnubApi.csproj +++ b/src/Api/PubnubApi/PubnubApi.csproj @@ -14,7 +14,7 @@ Pubnub - 8.2.1 + 8.3.0 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,8 +22,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Fixes issue of hereNow returning incorrect Occupancy when Occupancy count value is omitted from the response of single channel hereNow call. -Fix for `HereNow` omitting the channel entry for an empty channel when UUIDs are included. + Added a EnableHttp2 flag to the config which if set to true (the default value) will make all calls in the HttpClientService request Http/2 by default and fall back to Http/1.1 if not available on the origin. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj index a94cb101f..33df1695a 100644 --- a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj +++ b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj @@ -14,7 +14,7 @@ PubnubPCL - 8.2.1 + 8.3.0 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,8 +22,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Fixes issue of hereNow returning incorrect Occupancy when Occupancy count value is omitted from the response of single channel hereNow call. -Fix for `HereNow` omitting the channel entry for an empty channel when UUIDs are included. + Added a EnableHttp2 flag to the config which if set to true (the default value) will make all calls in the HttpClientService request Http/2 by default and fall back to Http/1.1 if not available on the origin. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj index 35483149c..435ff58ca 100644 --- a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj +++ b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj @@ -16,7 +16,7 @@ PubnubUWP - 8.2.1 + 8.3.0 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -24,8 +24,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Fixes issue of hereNow returning incorrect Occupancy when Occupancy count value is omitted from the response of single channel hereNow call. -Fix for `HereNow` omitting the channel entry for an empty channel when UUIDs are included. + Added a EnableHttp2 flag to the config which if set to true (the default value) will make all calls in the HttpClientService request Http/2 by default and fall back to Http/1.1 if not available on the origin. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj index dc1c2bec3..b1a3c971f 100644 --- a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj +++ b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj @@ -15,7 +15,7 @@ PubnubApiUnity - 8.2.1 + 8.3.0 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub