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/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/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/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 a38e0f745..7c44e8ea9 100644
--- a/src/Api/PubnubApi/PubnubApi.csproj
+++ b/src/Api/PubnubApi/PubnubApi.csproj
@@ -1,7 +1,7 @@
- net60
+ net6.0
latest
True
pubnub.snk
@@ -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/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/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
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
+