You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by lz...@apache.org on 2026/09/30 18:15:55 UTC

(iggy) branch feat/csharp-connection-string updated (e9209ae11 -> fc113e58a)

This is an automated email from the ASF dual-hosted git repository.

lukaszzborek pushed a change to branch feat/csharp-connection-string
in repository https://gitbox.apache.org/repos/asf/iggy.git


 discard e9209ae11 feat(csharp): implement connection string parsing
     new fc113e58a feat(csharp): implement connection string parsing

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (e9209ae11)
            \
             N -- N -- N   refs/heads/feat/csharp-connection-string (fc113e58a)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../Iggy_SDK/Configuration/ConnectionString.cs     | 182 ++++++++++++---------
 1 file changed, 105 insertions(+), 77 deletions(-)


(iggy) 01/01: feat(csharp): implement connection string parsing

Posted by lz...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lukaszzborek pushed a commit to branch feat/csharp-connection-string
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit fc113e58a95785094b1f349d3b3c14d5bebad190
Author: Łukasz Zborek <lu...@gmail.com>
AuthorDate: Tue Sep 29 23:23:48 2026 +0200

    feat(csharp): implement connection string parsing
---
 .../ConnectionStringTests.cs                       | 104 +++++
 .../IggyTlsConnectionTests.cs                      |  16 +
 .../Iggy_SDK/Configuration/ConnectionString.cs     | 250 ++++++++++++
 .../Configuration/IggyClientConfigurator.cs        |  38 ++
 .../csharp/Iggy_SDK/Factory/IggyClientFactory.cs   |  21 +-
 .../csharp/Iggy_SDK/Utils/IggyDurationParser.cs    | 399 +++++++++++++++++++
 .../ClientTests/ConnectionStringTests.cs           | 442 +++++++++++++++++++++
 .../UtilityTests/IggyDurationParserTests.cs        | 227 +++++++++++
 foreign/csharp/README.md                           |  44 ++
 9 files changed, 1537 insertions(+), 4 deletions(-)

diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs
new file mode 100644
index 000000000..3e677b059
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs
@@ -0,0 +1,104 @@
+// 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.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Factory;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+public class ConnectionStringTests
+{
+    // Below the fixture's server heartbeat, or an idle client is evicted mid-test.
+    private const string Heartbeat = "heartbeat_interval=1s";
+
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task ConnectionString_WithEveryOption_Should_SignIn()
+    {
+        var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+        var connectionString = $"iggy://iggy:iggy@{address}?nodelay=true&reconnection_retries=1"
+                               + $"&reconnection_interval=1s&reestablish_after=5s&tls=false&{Heartbeat}";
+
+        var config = IggyClientConfigurator.FromConnectionString(connectionString);
+        config.ReconnectionSettings.MaxRetries.ShouldBe(1);
+        config.ReconnectionSettings.InitialDelay.ShouldBe(TimeSpan.FromSeconds(1));
+        config.HeartbeatInterval.ShouldBe(TimeSpan.FromSeconds(1));
+        config.TlsSettings.Enabled.ShouldBeFalse();
+
+        using var client = IggyClientFactory.CreateClient(config);
+        await client.ConnectAsync();
+
+        await Should.NotThrowAsync(client.PingAsync());
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.UserId.ShouldBe(0u);
+    }
+
+    [Test]
+    [Arguments("iggy")]
+    [Arguments("iggy+tcp")]
+    public async Task ConnectionString_WithPassword_Should_SignIn(string scheme)
+    {
+        var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+        using var client = IggyClientFactory.CreateClient($"{scheme}://iggy:iggy@{address}?{Heartbeat}");
+        await client.ConnectAsync();
+
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.UserId.ShouldBe(0u);
+    }
+
+    [Test]
+    public async Task ConnectionString_WithPersonalAccessToken_Should_SignIn()
+    {
+        using var admin = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var name = $"cs-{Guid.NewGuid():N}"[..20];
+        var token = await admin.CreatePersonalAccessTokenAsync(name, TimeSpan.FromHours(1));
+        var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+        using var client = IggyClientFactory.CreateClient($"iggy+tcp://{token!.Token}@{address}?{Heartbeat}");
+        await client.ConnectAsync();
+
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.UserId.ShouldBe(0u);
+    }
+
+    [Test]
+    [Arguments("reconnection_retries=unlimited")]
+    [Arguments("reconnection_retries=10&reconnection_interval=250ms")]
+    [Arguments("reconnection_interval=1m30s")]
+    [Arguments("reconnection_retries=0")]
+    [Arguments("nodelay=false")]
+    [Arguments("reestablish_after=7s")]
+    [Arguments("reestablish_after=0")]
+    public async Task ConnectionString_WithAcceptedOptionValues_Should_ReachTheServer(string options)
+    {
+        var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+        using var client = IggyClientFactory.CreateClient($"iggy://iggy:iggy@{address}?{options}&{Heartbeat}");
+        await client.ConnectAsync();
+
+        await Should.NotThrowAsync(client.PingAsync());
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
index a89da78be..e5ac343f3 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
@@ -101,4 +101,20 @@ public class IggyTlsConnectionTests
 
         loginResult.ShouldNotBeNull();
     }
+
+    [Test]
+    public async Task Connect_WithTlsConnectionString_Should_SignIn()
+    {
+        var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+        // The heartbeat stays below the fixture's server heartbeat, or an idle client is evicted mid-test.
+        using var client = IggyClientFactory.CreateClient(
+            $"iggy://iggy:iggy@{address}?tls=true&tls_domain=localhost&tls_ca_file=Certs/iggy_ca_cert.pem"
+            + "&reconnection_retries=0&heartbeat_interval=1s");
+        await client.ConnectAsync();
+
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.UserId.ShouldBe(0u);
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs b/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs
new file mode 100644
index 000000000..4fd341f88
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs
@@ -0,0 +1,250 @@
+// 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.
+
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Configuration;
+
+// Parsed by hand because Uri escaping and host normalization accept strings this format rejects.
+internal static class ConnectionString
+{
+    private const string DefaultScheme = "iggy";
+    private const string TcpScheme = "iggy+tcp";
+    private const string SchemePrefix = "iggy+";
+
+    // Unbracketed hosts may not contain ':', so 2001:db8::1:8090 and host:1:2 are rejected.
+    private static readonly Regex AuthorityPattern = new(@"^(?:\[([^\]\s\p{Cc}]+)\]|([^:\[\]\s\p{Cc}]+)):([0-9]+)\z",
+        RegexOptions.CultureInvariant);
+
+    internal static IggyClientConfigurator Parse(string connectionString)
+    {
+        ArgumentNullException.ThrowIfNull(connectionString);
+
+        var protocolParts = connectionString.Split("://");
+        if (protocolParts.Length != 2)
+        {
+            throw Invalid();
+        }
+
+        ValidateScheme(protocolParts[0]);
+
+        var parts = protocolParts[1].Split('@');
+        if (parts.Length != 2)
+        {
+            throw Invalid();
+        }
+
+        var autoLoginSettings = ParseCredentials(parts[0]);
+
+        var serverAndOptions = parts[1].Split('?');
+        if (serverAndOptions.Length > 2)
+        {
+            throw Invalid();
+        }
+
+        var serverAddress = serverAndOptions[0];
+        var serverHost = ParseHost(serverAddress);
+        var (tlsSettings, reconnectionSettings, heartbeatInterval) =
+            ParseOptions(serverAndOptions.ElementAtOrDefault(1), serverHost);
+
+        return new IggyClientConfigurator
+        {
+            BaseAddress = serverAddress,
+            Protocol = Protocol.Tcp,
+            HeartbeatInterval = heartbeatInterval,
+            TlsSettings = tlsSettings,
+            ReconnectionSettings = reconnectionSettings,
+            AutoLoginSettings = autoLoginSettings
+        };
+    }
+
+    private static void ValidateScheme(string scheme)
+    {
+        if (scheme == DefaultScheme || scheme == TcpScheme)
+        {
+            return;
+        }
+
+        if (scheme.Equals(DefaultScheme, StringComparison.OrdinalIgnoreCase)
+            || scheme.Equals(TcpScheme, StringComparison.OrdinalIgnoreCase))
+        {
+            throw new FormatException(
+                $"Connection string schemes are case-sensitive, use \"{DefaultScheme}\" or \"{TcpScheme}\".");
+        }
+
+        // Only a plain transport name is echoed, a malformed scheme may carry credentials.
+        if (scheme.StartsWith(SchemePrefix, StringComparison.Ordinal)
+            && scheme.Length > SchemePrefix.Length
+            && scheme[SchemePrefix.Length..].All(char.IsAsciiLetter))
+        {
+            throw new FormatException(
+                $"Unsupported transport \"{scheme[SchemePrefix.Length..]}\", connection strings support tcp only.");
+        }
+
+        throw Invalid();
+    }
+
+    private static AutoLoginSettings ParseCredentials(string userInfo)
+    {
+        var credentials = userInfo.Split(':');
+        if (credentials.Length > 2 || credentials.Any(string.IsNullOrEmpty))
+        {
+            throw Invalid();
+        }
+
+        return credentials.Length == 1
+            ? AutoLoginSettings.ForPersonalAccessToken(credentials[0])
+            : AutoLoginSettings.For(credentials[0], credentials[1]);
+    }
+
+    private static string ParseHost(string serverAddress)
+    {
+        var addressMatch = AuthorityPattern.Match(serverAddress);
+        if (!addressMatch.Success || !ushort.TryParse(addressMatch.Groups[3].Value, NumberStyles.None,
+                CultureInfo.InvariantCulture, out _))
+        {
+            throw Invalid();
+        }
+
+        return addressMatch.Groups[1].Success
+            ? addressMatch.Groups[1].Value
+            : addressMatch.Groups[2].Value;
+    }
+
+    private static (TlsSettings, ReconnectionSettings, TimeSpan) ParseOptions(string? query, string serverHost)
+    {
+        // The certificate name check needs a hostname when tls_domain is missing.
+        var tlsSettings = new TlsSettings { Hostname = serverHost };
+        var reconnectionSettings = new ReconnectionSettings
+        {
+            Enabled = true,
+            MaxRetries = 0,
+            InitialDelay = TimeSpan.FromSeconds(1),
+            UseExponentialBackoff = false
+        };
+        var heartbeatInterval = TimeSpan.FromSeconds(5);
+
+        foreach (var option in query?.Split('&') ?? [])
+        {
+            var (name, value) = SplitOption(option);
+            switch (name)
+            {
+                case "tls":
+                    tlsSettings.Enabled = ParseBoolean(name, value);
+                    break;
+                case "tls_domain":
+                    tlsSettings.Hostname = value.Length == 0 ? serverHost : value;
+                    break;
+                case "tls_ca_file":
+                    tlsSettings.CertificatePath = value;
+                    break;
+                case "reconnection_retries":
+                    ApplyReconnectionRetries(reconnectionSettings, name, value);
+                    break;
+                case "reconnection_interval":
+                    reconnectionSettings.InitialDelay = ParseInterval(name, value);
+                    break;
+                case "reestablish_after":
+                    // Validated only, reconnection_interval paces every redial.
+                    ParseDuration(name, value, IggyDurationParser.ParseParts);
+                    break;
+                case "heartbeat_interval":
+                    heartbeatInterval = ParseInterval(name, value);
+                    break;
+                case "nodelay":
+                    // Validated only, the TCP client always disables Nagle.
+                    ParseBoolean(name, value);
+                    break;
+                default:
+                    throw new FormatException($"Unknown option \"{name}\".");
+            }
+        }
+
+        if (tlsSettings.Enabled && string.IsNullOrEmpty(tlsSettings.CertificatePath))
+        {
+            throw new FormatException("Option \"tls\" requires \"tls_ca_file\".");
+        }
+
+        return (tlsSettings, reconnectionSettings, heartbeatInterval);
+    }
+
+    private static (string Name, string Value) SplitOption(string option)
+    {
+        var optionParts = option.Split('=');
+        if (optionParts.Length != 2)
+        {
+            throw Invalid();
+        }
+
+        return (optionParts[0], optionParts[1]);
+    }
+
+    private static void ApplyReconnectionRetries(ReconnectionSettings settings, string name, string value)
+    {
+        var unlimited = value == "unlimited";
+        uint retries = 0;
+        if (!unlimited && !uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out retries))
+        {
+            throw new FormatException($"Option \"{name}\" must be \"unlimited\" or an integer up to {uint.MaxValue}.");
+        }
+
+        // MaxRetries treats 0 as unlimited, so zero retries means reconnection off.
+        settings.Enabled = unlimited || retries != 0;
+        settings.MaxRetries = retries <= int.MaxValue ? (int)retries : 0;
+    }
+
+    private static T ParseDuration<T>(string name, string value, Func<string, T> parse)
+    {
+        try
+        {
+            return parse(value);
+        }
+        catch (FormatException e)
+        {
+            throw new FormatException($"Option \"{name}\" has an invalid duration: {e.Message}.", e);
+        }
+    }
+
+    private static TimeSpan ParseInterval(string name, string value)
+    {
+        var interval = ParseDuration(name, value, IggyDurationParser.Parse);
+        if (interval < IggyClientConfigurator.MinInterval || interval > IggyClientConfigurator.MaxInterval)
+        {
+            throw new FormatException($"Option \"{name}\" must be between 1 millisecond and about 49 days.");
+        }
+
+        return interval;
+    }
+
+    private static bool ParseBoolean(string name, string value)
+    {
+        return value switch
+        {
+            "true" => true,
+            "false" => false,
+            _ => throw new FormatException($"Option \"{name}\" must be true or false.")
+        };
+    }
+
+    private static FormatException Invalid()
+    {
+        return new FormatException("Invalid connection string.");
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
index d959886c0..e126c6681 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
@@ -27,6 +27,11 @@ namespace Apache.Iggy.Configuration;
 /// </summary>
 public sealed class IggyClientConfigurator
 {
+    // The bounds Task.Delay and PeriodicTimer accept, which the reconnect loop and heartbeat run on. Below the
+    // minimum the delay truncates to zero and redials in a hot loop.
+    internal static readonly TimeSpan MinInterval = TimeSpan.FromMilliseconds(1);
+    internal static readonly TimeSpan MaxInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+
     /// <summary>
     ///     The base address of the Iggy server.
     /// </summary>
@@ -100,4 +105,37 @@ public sealed class IggyClientConfigurator
     ///     would silently skip the batch. Does not affect <see cref="Consumers.IggyConsumer" /> commit modes.
     /// </summary>
     public bool AllowAutoCommitWithEncryptor { get; set; }
+
+    /// <summary>
+    ///     Creates a TCP configuration from a connection string in the format shared by every Iggy SDK:
+    ///     <c>iggy://username:password@host:port?options</c>, or <c>iggy://token@host:port</c> to sign in with a
+    ///     personal access token. The <c>iggy+tcp://</c> scheme is accepted as well. The credentials become the
+    ///     <see cref="AutoLoginSettings" />; the remaining settings can still be changed on the returned instance, except
+    ///     <see cref="HeartbeatInterval" />, which is init-only: <c>heartbeat_interval</c> in the string is the only
+    ///     way to set it.
+    /// </summary>
+    /// <remarks>
+    ///     Credentials are taken literally: they are not percent-decoded and must not contain <c>@</c> or <c>:</c>.
+    ///     Supported options: <c>tls</c>, <c>tls_domain</c>, <c>tls_ca_file</c>, <c>reconnection_retries</c>
+    ///     (a count or <c>unlimited</c>), <c>reconnection_interval</c>, <c>heartbeat_interval</c>,
+    ///     <c>reestablish_after</c> and <c>nodelay</c>. Durations look like <c>500ms</c>, <c>5s</c> or <c>1m30s</c>.
+    ///     Reconnection defaults to unlimited retries every second, and after each reconnect the client still waits
+    ///     <see cref="ReconnectionSettings.WaitAfterReconnect" /> (1 second by default). <c>reestablish_after</c> is
+    ///     validated but ignored, since <c>reconnection_interval</c> paces every redial. <c>nodelay</c> is validated
+    ///     but ignored too: sockets are always opened with <c>NoDelay</c>, so <c>nodelay=false</c> does not turn
+    ///     Nagle back on. <c>tls=true</c> requires
+    ///     <c>tls_ca_file</c>, and one <c>tls_domain</c> covers every node the client dials.
+    ///     <c>reconnection_retries=0</c> turns reconnection off entirely, so the client does not reconnect after a
+    ///     lost connection either.
+    /// </remarks>
+    /// <param name="connectionString">The connection string.</param>
+    /// <exception cref="ArgumentNullException">Thrown when <paramref name="connectionString" /> is null.</exception>
+    /// <exception cref="FormatException">
+    ///     Thrown when the connection string is malformed, names a transport other than TCP or holds an unknown or
+    ///     invalid option. The message never contains the connection string itself.
+    /// </exception>
+    public static IggyClientConfigurator FromConnectionString(string connectionString)
+    {
+        return ConnectionString.Parse(connectionString);
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
index 091565ace..5316d7a11 100644
--- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
+++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
@@ -61,6 +61,19 @@ public static class IggyClientFactory
         };
     }
 
+    /// <summary>
+    ///     Creates a TCP client from a connection string such as <c>iggy://iggy:iggy@127.0.0.1:8090</c>. See
+    ///     <see cref="IggyClientConfigurator.FromConnectionString" /> for the format and the supported options.
+    /// </summary>
+    /// <param name="connectionString">The connection string.</param>
+    /// <returns>An unconnected <see cref="IIggyClient" /> that signs in with the connection string credentials.</returns>
+    /// <exception cref="ArgumentNullException">Thrown when <paramref name="connectionString" /> is null.</exception>
+    /// <exception cref="FormatException">Thrown when the connection string is invalid.</exception>
+    public static IIggyClient CreateClient(string connectionString)
+    {
+        return CreateClient(IggyClientConfigurator.FromConnectionString(connectionString));
+    }
+
     private static void Validate(IggyClientConfigurator options)
     {
         if (options.Protocol == Protocol.Tcp && options.MaxResponseFrameSize < VsrHeader.HEADER_SIZE)
@@ -81,10 +94,10 @@ public static class IggyClientFactory
                 options.SendBufferSize, "SendBufferSize must be greater than 0 when set.");
         }
 
-        // The bounds PeriodicTimer accepts; anything outside them would fault the heartbeat task at start
-        // instead of failing the caller here.
-        if (options.HeartbeatInterval < TimeSpan.FromMilliseconds(1) ||
-            options.HeartbeatInterval > TimeSpan.FromMilliseconds(uint.MaxValue - 1))
+        // Outside these bounds PeriodicTimer would fault the heartbeat task at start instead of failing the
+        // caller here.
+        if (options.HeartbeatInterval < IggyClientConfigurator.MinInterval ||
+            options.HeartbeatInterval > IggyClientConfigurator.MaxInterval)
         {
             throw new ArgumentOutOfRangeException(nameof(options), options.HeartbeatInterval,
                 "HeartbeatInterval must be between 1 millisecond and about 49 days.");
diff --git a/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
new file mode 100644
index 000000000..8580304ff
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
@@ -0,0 +1,399 @@
+// 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.
+
+using System.Text;
+
+namespace Apache.Iggy.Utils;
+
+/// <summary>
+///     Parses humantime durations such as <c>5s</c> or <c>1h 30m</c>. The input is lowercased and the zero spellings
+///     are mapped first. The grammar keeps u64 overflow checks and exact fraction division, so a value that loses
+///     precision is rejected, not rounded.
+/// </summary>
+internal static class IggyDurationParser
+{
+    private const ulong NanosPerSecond = 1_000_000_000;
+
+    private const string OverflowMessage =
+        "number is too large or cannot be represented without a lack of precision (values below 1ns are not supported)";
+
+    private static readonly Dictionary<string, Unit> Units = new()
+    {
+        ["nanos"] = Unit.Nanosecond,
+        ["nsec"] = Unit.Nanosecond,
+        ["ns"] = Unit.Nanosecond,
+        ["usec"] = Unit.Microsecond,
+        ["us"] = Unit.Microsecond,
+        ["µs"] = Unit.Microsecond,
+        ["millis"] = Unit.Millisecond,
+        ["msec"] = Unit.Millisecond,
+        ["ms"] = Unit.Millisecond,
+        ["seconds"] = Unit.Second,
+        ["second"] = Unit.Second,
+        ["secs"] = Unit.Second,
+        ["sec"] = Unit.Second,
+        ["s"] = Unit.Second,
+        ["minutes"] = Unit.Minute,
+        ["minute"] = Unit.Minute,
+        ["min"] = Unit.Minute,
+        ["mins"] = Unit.Minute,
+        ["m"] = Unit.Minute,
+        ["hours"] = Unit.Hour,
+        ["hour"] = Unit.Hour,
+        ["hr"] = Unit.Hour,
+        ["hrs"] = Unit.Hour,
+        ["h"] = Unit.Hour,
+        ["days"] = Unit.Day,
+        ["day"] = Unit.Day,
+        ["d"] = Unit.Day,
+        ["weeks"] = Unit.Week,
+        ["week"] = Unit.Week,
+        ["wk"] = Unit.Week,
+        ["wks"] = Unit.Week,
+        ["w"] = Unit.Week,
+        ["months"] = Unit.Month,
+        ["month"] = Unit.Month,
+        ["years"] = Unit.Year,
+        ["year"] = Unit.Year,
+        ["yr"] = Unit.Year,
+        ["yrs"] = Unit.Year,
+        ["y"] = Unit.Year
+    };
+
+    private enum Unit
+    {
+        Nanosecond,
+        Microsecond,
+        Millisecond,
+        Second,
+        Minute,
+        Hour,
+        Day,
+        Week,
+        Month,
+        Year
+    }
+
+    /// <summary>
+    ///     Parses a duration such as <c>5s</c>, <c>1h 30m</c> or <c>unlimited</c> (zero).
+    /// </summary>
+    /// <exception cref="FormatException">
+    ///     Thrown when the value does not parse or does not fit in a <see cref="TimeSpan" />.
+    /// </exception>
+    internal static TimeSpan Parse(string value)
+    {
+        var (seconds, nanoseconds) = ParseParts(value);
+        try
+        {
+            // TimeSpan counts 100 ns ticks, so a sub-tick remainder is dropped.
+            return TimeSpan.FromTicks(checked((long)seconds * TimeSpan.TicksPerSecond + nanoseconds / 100));
+        }
+        catch (OverflowException)
+        {
+            throw new FormatException(OverflowMessage);
+        }
+    }
+
+    /// <summary>
+    ///     Parses like <see cref="Parse" />, but without the <see cref="TimeSpan" /> range limit.
+    /// </summary>
+    /// <exception cref="FormatException">Thrown when the value does not parse.</exception>
+    internal static (ulong Seconds, uint Nanoseconds) ParseParts(string value)
+    {
+        var lowered = value.ToLowerInvariant();
+        return lowered is "0" or "unlimited" or "disabled" or "none" ? (0, 0) : ParseHumantime(lowered);
+    }
+
+    /// <summary>
+    ///     Parses a lowercase humantime duration into whole seconds plus the subsecond nanoseconds.
+    /// </summary>
+    /// <exception cref="FormatException">Thrown when the value does not parse.</exception>
+    internal static (ulong Seconds, uint Nanoseconds) ParseHumantime(string input)
+    {
+        try
+        {
+            return new Parser(input).Parse();
+        }
+        catch (OverflowException)
+        {
+            throw new FormatException(OverflowMessage);
+        }
+    }
+
+    private static ulong Mul(ulong left, ulong right)
+    {
+        return checked(left * right);
+    }
+
+    private static ulong Add(ulong left, ulong right)
+    {
+        return checked(left + right);
+    }
+
+    // A division that is not exact would lose precision, which humantime reports as an overflow.
+    private static ulong Div(ulong dividend, ulong divisor)
+    {
+        if (dividend % divisor != 0)
+        {
+            throw new FormatException(OverflowMessage);
+        }
+
+        return dividend / divisor;
+    }
+
+    private readonly record struct Fraction(ulong Numerator, ulong Denominator);
+
+    /// <summary>
+    ///     Walks the input by char. Every accepted character, including Unicode whitespace and <c>µ</c>, is a single
+    ///     UTF-16 char, so a surrogate fails at its first half. Error offsets are UTF-8 byte offsets.
+    /// </summary>
+    private sealed class Parser(string source)
+    {
+        private int _index;
+        private ulong _nanoseconds;
+        private ulong _seconds;
+
+        internal (ulong Seconds, uint Nanoseconds) Parse()
+        {
+            var number = ParseFirstChar() ?? throw new FormatException("value was empty");
+            while (true)
+            {
+                Fraction? fraction = null;
+                var offset = _index;
+                while (Next() is { } c)
+                {
+                    if (IsDigit(c))
+                    {
+                        number = Add(Mul(number, 10), Digit(c));
+                    }
+                    else if (char.IsWhiteSpace(c))
+                    {
+                    }
+                    else if (IsUnitChar(c))
+                    {
+                        break;
+                    }
+                    else if (c == '.')
+                    {
+                        fraction = ParseFractionalPart(ref offset);
+                        break;
+                    }
+                    else
+                    {
+                        throw InvalidCharacter(offset);
+                    }
+
+                    offset = _index;
+                }
+
+                var start = offset;
+                offset = _index;
+                var nextNumber = false;
+                while (Next() is { } c)
+                {
+                    if (IsDigit(c))
+                    {
+                        AddUnit(number, fraction, start, offset);
+                        number = Digit(c);
+                        nextNumber = true;
+                        break;
+                    }
+
+                    if (char.IsWhiteSpace(c))
+                    {
+                        break;
+                    }
+
+                    if (!IsUnitChar(c))
+                    {
+                        throw InvalidCharacter(offset);
+                    }
+
+                    offset = _index;
+                }
+
+                if (nextNumber)
+                {
+                    continue;
+                }
+
+                AddUnit(number, fraction, start, offset);
+                if (ParseFirstChar() is not { } next)
+                {
+                    return (_seconds, (uint)_nanoseconds);
+                }
+
+                number = next;
+            }
+        }
+
+        private ulong? ParseFirstChar()
+        {
+            var offset = _index;
+            while (Next() is { } c)
+            {
+                if (IsDigit(c))
+                {
+                    return Digit(c);
+                }
+
+                if (!char.IsWhiteSpace(c))
+                {
+                    throw new FormatException($"expected number at {ByteOffset(offset)}");
+                }
+            }
+
+            return null;
+        }
+
+        private Fraction ParseFractionalPart(ref int offset)
+        {
+            ulong numerator = 0;
+            ulong denominator = 1;
+            var zeros = true;
+            while (Next() is { } c)
+            {
+                if (c == '0')
+                {
+                    denominator = Mul(denominator, 10);
+                    if (!zeros)
+                    {
+                        numerator = Mul(numerator, 10);
+                    }
+                }
+                else if (IsDigit(c))
+                {
+                    zeros = false;
+                    denominator = Mul(denominator, 10);
+                    numerator = Add(Mul(numerator, 10), Digit(c));
+                }
+                else if (char.IsWhiteSpace(c))
+                {
+                }
+                else if (IsUnitChar(c))
+                {
+                    break;
+                }
+                else
+                {
+                    throw InvalidCharacter(offset);
+                }
+
+                offset = _index;
+            }
+
+            // No digits after the separator, e.g. "1.".
+            if (denominator == 1)
+            {
+                throw InvalidCharacter(offset);
+            }
+
+            return new Fraction(numerator, denominator);
+        }
+
+        private void AddUnit(ulong number, Fraction? fraction, int start, int end)
+        {
+            var unitText = source[start..end];
+            if (!Units.TryGetValue(unitText, out var unit))
+            {
+                throw new FormatException(unitText.Length == 0
+                    ? $"time unit needed, for example {number}sec or {number}ms"
+                    : $"unknown time unit \"{unitText}\", supported units: ns, us/µs, ms, sec, min, hours, days, "
+                      + "weeks, months, years (and few variations)");
+            }
+
+            var (seconds, nanoseconds) = unit switch
+            {
+                Unit.Nanosecond => (0UL, number),
+                Unit.Microsecond => (0UL, Mul(number, 1000)),
+                Unit.Millisecond => (0UL, Mul(number, 1_000_000)),
+                Unit.Second => (number, 0UL),
+                Unit.Minute => (Mul(number, 60), 0UL),
+                Unit.Hour => (Mul(number, 3600), 0UL),
+                Unit.Day => (Mul(number, 86_400), 0UL),
+                Unit.Week => (Mul(number, 86_400 * 7), 0UL),
+                Unit.Month => (Mul(number, 2_630_016), 0UL),
+                _ => (Mul(number, 31_557_600), 0UL)
+            };
+            AddCurrent(seconds, nanoseconds);
+
+            if (fraction is not { } part)
+            {
+                return;
+            }
+
+            var (numerator, denominator) = (part.Numerator, part.Denominator);
+            (seconds, nanoseconds) = unit switch
+            {
+                Unit.Nanosecond => throw new FormatException(OverflowMessage),
+                Unit.Microsecond => (0UL, Div(Mul(numerator, 1000), denominator)),
+                Unit.Millisecond => (0UL, Div(Mul(numerator, 1_000_000), denominator)),
+                Unit.Second => (0UL, Div(Mul(numerator, NanosPerSecond), denominator)),
+                Unit.Minute => (0UL, Div(Mul(numerator, 60 * NanosPerSecond), denominator)),
+                Unit.Hour => (Div(Mul(numerator, 3600), denominator), 0UL),
+                Unit.Day => (Div(Mul(numerator, 86_400), denominator), 0UL),
+                Unit.Week => (Div(Mul(numerator, 86_400 * 7), denominator), 0UL),
+                Unit.Month => (Div(Mul(numerator, 2_630_016), denominator), 0UL),
+                _ => (Div(Mul(numerator, 31_557_600), denominator), 0UL)
+            };
+            AddCurrent(seconds, nanoseconds);
+        }
+
+        private void AddCurrent(ulong seconds, ulong nanoseconds)
+        {
+            nanoseconds = Add(_nanoseconds, nanoseconds);
+            if (nanoseconds >= NanosPerSecond)
+            {
+                seconds = Add(seconds, nanoseconds / NanosPerSecond);
+                nanoseconds %= NanosPerSecond;
+            }
+
+            _seconds = Add(_seconds, seconds);
+            _nanoseconds = nanoseconds;
+        }
+
+        private char? Next()
+        {
+            return _index < source.Length ? source[_index++] : null;
+        }
+
+        private FormatException InvalidCharacter(int offset)
+        {
+            return new FormatException($"invalid character at {ByteOffset(offset)}");
+        }
+
+        private int ByteOffset(int offset)
+        {
+            return Encoding.UTF8.GetByteCount(source.AsSpan(0, offset));
+        }
+
+        private static bool IsDigit(char c)
+        {
+            return c is >= '0' and <= '9';
+        }
+
+        private static ulong Digit(char c)
+        {
+            return (ulong)(c - '0');
+        }
+
+        private static bool IsUnitChar(char c)
+        {
+            return c is >= 'a' and <= 'z' or 'µ';
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
new file mode 100644
index 000000000..3c179840e
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
@@ -0,0 +1,442 @@
+// 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.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Factory;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+public sealed class ConnectionStringTests
+{
+    [Theory]
+    [InlineData("iggy://user:secret@127.0.0.1:1234")]
+    [InlineData("iggy+tcp://user:secret@127.0.0.1:1234")]
+    public void FromConnectionString_WithoutOptions_AppliesDefaults(string connectionString)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(connectionString);
+
+        Assert.Equal("127.0.0.1:1234", config.BaseAddress);
+        Assert.Equal(Protocol.Tcp, config.Protocol);
+        Assert.True(config.AutoLoginSettings.Enabled);
+        Assert.Equal("user", config.AutoLoginSettings.Username);
+        Assert.Equal("secret", config.AutoLoginSettings.Password);
+        Assert.Empty(config.AutoLoginSettings.PersonalAccessToken);
+
+        Assert.False(config.TlsSettings.Enabled);
+        Assert.Equal("127.0.0.1", config.TlsSettings.Hostname);
+        Assert.Empty(config.TlsSettings.CertificatePath);
+        Assert.Equal(TimeSpan.FromSeconds(5), config.HeartbeatInterval);
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(1), config.ReconnectionSettings.InitialDelay);
+        Assert.False(config.ReconnectionSettings.UseExponentialBackoff);
+    }
+
+    [Theory]
+    [InlineData("iggy://iggypat-1234567890abcdef@127.0.0.1:1234")]
+    [InlineData("iggy+tcp://iggypat-1234567890abcdef@localhost:1234")]
+    public void FromConnectionString_WithPersonalAccessToken_SignsInWithToken(string connectionString)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(connectionString);
+
+        Assert.True(config.AutoLoginSettings.Enabled);
+        Assert.Equal("iggypat-1234567890abcdef", config.AutoLoginSettings.PersonalAccessToken);
+        Assert.Empty(config.AutoLoginSettings.Username);
+        Assert.Empty(config.AutoLoginSettings.Password);
+        Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(5), config.HeartbeatInterval);
+    }
+
+    [Theory]
+    [InlineData("reconnection_retries=3&heartbeat_interval=10s", 3)]
+    [InlineData("heartbeat_interval=10s&reconnection_retries=10", 10)]
+    public void FromConnectionString_WithRetriesAndHeartbeat_MapsBoth(string options, int retries)
+    {
+        var config = IggyClientConfigurator.FromConnectionString($"iggy+tcp://user:secret@127.0.0.1:1234?{options}");
+
+        Assert.Equal("127.0.0.1:1234", config.BaseAddress);
+        Assert.Equal("user", config.AutoLoginSettings.Username);
+        Assert.Equal("secret", config.AutoLoginSettings.Password);
+        Assert.False(config.TlsSettings.Enabled);
+        Assert.Equal(TimeSpan.FromSeconds(10), config.HeartbeatInterval);
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(retries, config.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(1), config.ReconnectionSettings.InitialDelay);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithReconnectionOptions_MapsIntervalAndRetries()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy+tcp://iggy:secret@localhost:8090?reconnection_retries=3&reconnection_interval=5s&heartbeat_interval=10s");
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(3, config.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(5), config.ReconnectionSettings.InitialDelay);
+        Assert.False(config.ReconnectionSettings.UseExponentialBackoff);
+        Assert.Equal(TimeSpan.FromSeconds(10), config.HeartbeatInterval);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithPartialReconnectionOptions_KeepsTheOtherDefault()
+    {
+        var retriesOnly = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_retries=3");
+        var intervalOnly = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_interval=5s");
+
+        Assert.Equal(3, retriesOnly.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(1), retriesOnly.ReconnectionSettings.InitialDelay);
+        Assert.Equal(0, intervalOnly.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(5), intervalOnly.ReconnectionSettings.InitialDelay);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithTlsOptions_MapsTlsSettings()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?tls=true&tls_domain=iggy.apache.org&tls_ca_file=/does/not/exist.pem");
+
+        Assert.True(config.TlsSettings.Enabled);
+        Assert.Equal("iggy.apache.org", config.TlsSettings.Hostname);
+        // Stored as a path: the file is read when the TLS stream is created, not at parse time.
+        Assert.Equal("/does/not/exist.pem", config.TlsSettings.CertificatePath);
+        Assert.Equal("localhost:8090", config.BaseAddress);
+    }
+
+    [Theory]
+    [InlineData("tls=false")]
+    [InlineData("nodelay=true")]
+    [InlineData("nodelay=false")]
+    public void FromConnectionString_WithBooleanOptions_Accepts(string options)
+    {
+        var config = IggyClientConfigurator.FromConnectionString($"iggy://iggy:secret@localhost:8090?{options}");
+
+        Assert.False(config.TlsSettings.Enabled);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithUnlimitedRetries_KeepsRetriesUnlimited()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_retries=unlimited");
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithZeroRetries_DisablesReconnection()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_retries=0");
+
+        Assert.False(config.ReconnectionSettings.Enabled);
+    }
+
+    [Theory]
+    [InlineData("0", "5", true, 5)]
+    [InlineData("3", "unlimited", true, 0)]
+    [InlineData("3", "0", false, 0)]
+    [InlineData("3", "4294967295", true, 0)]
+    public void FromConnectionString_WithRepeatedRetries_UsesTheLastValue(string first, string last, bool enabled,
+        int maxRetries)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            $"iggy://iggy:secret@localhost:8090?reconnection_retries={first}&reconnection_retries={last}");
+
+        Assert.Equal(enabled, config.ReconnectionSettings.Enabled);
+        Assert.Equal(maxRetries, config.ReconnectionSettings.MaxRetries);
+    }
+
+    [Theory]
+    [InlineData("2147483647", int.MaxValue)]
+    [InlineData("2147483648", 0)]
+    [InlineData("4294967295", 0)]
+    public void FromConnectionString_WithRetriesUpToU32Max_Accepts(string retries, int expected)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            $"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}");
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(expected, config.ReconnectionSettings.MaxRetries);
+    }
+
+    [Theory]
+    [InlineData("4294967296")]
+    [InlineData("99999999999999")]
+    [InlineData("three")]
+    [InlineData("-1")]
+    [InlineData("+3")]
+    [InlineData("")]
+    public void FromConnectionString_WithInvalidRetries_Throws(string retries)
+    {
+        Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            $"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}"));
+    }
+
+    [Theory]
+    [InlineData("0")]
+    [InlineData("0ms")]
+    [InlineData("500us")]
+    [InlineData("none")]
+    [InlineData("50d")]
+    public void FromConnectionString_WithReconnectionIntervalOutOfRange_Throws(string interval)
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            $"iggy://iggy:secret@localhost:8090?reconnection_interval={interval}"));
+
+        Assert.Contains("must be between 1 millisecond", exception.Message);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithReconnectionIntervalAtTimerCeiling_Accepts()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_interval=49d");
+
+        Assert.Equal(TimeSpan.FromDays(49), config.ReconnectionSettings.InitialDelay);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithNegativeReconnectionInterval_Throws()
+    {
+        Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@localhost:8090?reconnection_interval=-1s"));
+    }
+
+    [Theory]
+    [InlineData("none")]
+    [InlineData("0")]
+    [InlineData("0ms")]
+    [InlineData("500us")]
+    [InlineData("50d")]
+    public void FromConnectionString_WithHeartbeatIntervalOutOfRange_Throws(string interval)
+    {
+        Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            $"iggy+tcp://user:secret@127.0.0.1:1234?heartbeat_interval={interval}"));
+    }
+
+    [Theory]
+    [InlineData("0")]
+    [InlineData("10s")]
+    [InlineData("100000y")]
+    public void FromConnectionString_WithReestablishAfter_ValidatesAndIgnoresIt(string value)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            $"iggy+tcp://user:secret@127.0.0.1:1234?reestablish_after={value}");
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+        Assert.Equal(TimeSpan.FromSeconds(1), config.ReconnectionSettings.InitialDelay);
+        Assert.Equal(TimeSpan.FromSeconds(1), config.ReconnectionSettings.WaitAfterReconnect);
+    }
+
+    [Theory]
+    [InlineData("reconnection_interval=1m30s", 90_000)]
+    [InlineData("reconnection_interval=250ms", 250)]
+    [InlineData("reconnection_interval=1.5s", 1500)]
+    public void FromConnectionString_WithDurationSyntax_ParsesInterval(string options, int milliseconds)
+    {
+        var config = IggyClientConfigurator.FromConnectionString($"iggy://iggy:secret@localhost:8090?{options}");
+
+        Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), config.ReconnectionSettings.InitialDelay);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithTlsAndNoDomain_UsesTheServerHost()
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:secret@[::1]:8090?tls=true&tls_ca_file=/ca.pem");
+
+        Assert.Equal("::1", config.TlsSettings.Hostname);
+    }
+
+    [Theory]
+    [InlineData("tls_domain=")]
+    [InlineData("tls_domain=iggy.apache.org&tls_domain=")]
+    public void FromConnectionString_WithEmptyTlsDomain_UsesTheServerHost(string options)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            $"iggy://iggy:secret@localhost:8090?tls=true&tls_ca_file=/ca.pem&{options}");
+
+        Assert.Equal("localhost", config.TlsSettings.Hostname);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithTlsAndNoCaFile_Throws()
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            "iggy://iggy:hunter2@localhost:8090?tls=true&tls_domain=iggy.apache.org"));
+
+        Assert.Contains("tls_ca_file", exception.Message);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithSchemeTailHoldingCredentials_DoesNotEchoThem()
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(
+            "iggy+user:pw@host:8090?x=a://b"));
+
+        Assert.DoesNotContain("pw", exception.Message);
+    }
+
+    [Fact]
+    public void FromConnectionString_WithBracketedIpv6_KeepsTheBracketsForTheDialer()
+    {
+        var config = IggyClientConfigurator.FromConnectionString("iggy://iggy:secret@[::1]:8090");
+
+        Assert.Equal("[::1]:8090", config.BaseAddress);
+    }
+
+    [Theory]
+    [InlineData("iggy+quic://iggy:secret@localhost:8090")]
+    [InlineData("iggy+ws://iggy:secret@localhost:8090")]
+    [InlineData("iggy+http://iggy:secret@localhost:3000")]
+    [InlineData("iggy+http://iggypat-1234567890abcdef@localhost:3000")]
+    public void FromConnectionString_WithUnsupportedTransport_Throws(string connectionString)
+    {
+        var exception = Assert.Throws<FormatException>(
+            () => IggyClientConfigurator.FromConnectionString(connectionString));
+
+        Assert.Contains("Unsupported transport", exception.Message);
+    }
+
+    [Theory]
+    [InlineData("IGGY://iggy:secret@localhost:8090")]
+    [InlineData("iggy+TCP://iggy:secret@localhost:8090")]
+    [InlineData("Iggy+Tcp://iggy:secret@localhost:8090")]
+    public void FromConnectionString_WithUppercaseScheme_ThrowsCaseSensitiveError(string connectionString)
+    {
+        var exception = Assert.Throws<FormatException>(
+            () => IggyClientConfigurator.FromConnectionString(connectionString));
+
+        Assert.Contains("case-sensitive", exception.Message);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("iggy")]
+    [InlineData("iggy://")]
+    [InlineData("invalid+tcp://user:secret@127.0.0.1:1234")]
+    [InlineData("tcp://user:secret@127.0.0.1:1234")]
+    [InlineData("iggy://:secret@127.0.0.1:1234")]
+    [InlineData("iggy+tcp://:secret@127.0.0.1:1234")]
+    [InlineData("iggy://user:@127.0.0.1:1234")]
+    [InlineData("iggy+tcp://user:@127.0.0.1:1234")]
+    [InlineData("iggy://@127.0.0.1:1234")]
+    [InlineData("iggy://user:secret:extra@127.0.0.1:1234")]
+    [InlineData("iggy://user:secret@127.0.0.1:1234@other")]
+    [InlineData("iggy://user:secret@:1234")]
+    [InlineData("iggy+tcp://user:secret@:1234")]
+    [InlineData("iggy://user:secret@127.0.0.1:")]
+    [InlineData("iggy+tcp://user:secret@127.0.0.1:")]
+    [InlineData("iggy://user:secret@127.0.0.1")]
+    [InlineData("iggy://user:secret@localhost:port")]
+    [InlineData("iggy://user:secret@localhost:70000")]
+    [InlineData("iggy://user:secret@localhost:-1")]
+    [InlineData("iggy://user:secret@localhost:+80")]
+    [InlineData("iggy://user:secret@localhost:8090\n")]
+    [InlineData("iggy://user:secret@local host:8090")]
+    [InlineData("iggy://user:secret@local\nhost:8090")]
+    [InlineData("iggy://user:secret@[::1 ]:8090")]
+    [InlineData("iggy://user:secret@[::1:8090")]
+    [InlineData("iggy://user:secret@[]:8090")]
+    [InlineData("iggy://user:secret@[::1]x:8090")]
+    [InlineData("iggy://user:secret@2001:db8::1:8090")]
+    [InlineData("iggy://user:secret@host:8090:9090")]
+    [InlineData("iggy://user:secret@127.0.0.1:1234?invalid_option=invalid")]
+    [InlineData("iggy+tcp://user:secret@127.0.0.1:?invalid_option=invalid")]
+    [InlineData("iggy://user:secret@localhost:8090?unknown=value")]
+    [InlineData("iggy://user:secret@localhost:8090?tls=maybe")]
+    [InlineData("iggy://user:secret@localhost:8090?tls=TRUE")]
+    [InlineData("iggy://user:secret@localhost:8090?nodelay=1")]
+    [InlineData("iggy://user:secret@localhost:8090?")]
+    [InlineData("iggy://user:secret@localhost:8090?&")]
+    [InlineData("iggy://user:secret@localhost:8090?tls")]
+    [InlineData("iggy://user:secret@localhost:8090?tls=true=false")]
+    [InlineData("iggy://user:secret@localhost:8090?tls=true?nodelay=true")]
+    [InlineData("iggy://user:secret@localhost:8090?reestablish_after=garbage")]
+    [InlineData("iggy://user:secret@localhost:8090?heartbeat_interval=5")]
+    [InlineData("iggy://iggy://user:secret@localhost:8090")]
+    public void FromConnectionString_WithMalformedValue_Throws(string connectionString)
+    {
+        Assert.Throws<FormatException>(() => IggyClientConfigurator.FromConnectionString(connectionString));
+    }
+
+    [Fact]
+    public void FromConnectionString_WithNull_Throws()
+    {
+        Assert.Throws<ArgumentNullException>(() => IggyClientConfigurator.FromConnectionString(null!));
+    }
+
+    [Theory]
+    [InlineData("iggy://iggy:hunter2@localhost")]
+    [InlineData("iggy+tcp://iggypat-1234567890abcdef@localhost")]
+    [InlineData("iggy://iggy:hunter2@localhost:8090?unknown=value")]
+    [InlineData("iggy://iggy:hunter2@localhost:8090?tls=maybe")]
+    [InlineData("iggy://iggy:hunter2@localhost:8090?reconnection_retries=three")]
+    [InlineData("iggy://iggy:hunter2@localhost:8090?reconnection_interval=0")]
+    [InlineData("iggy://iggy:hunter2@localhost:8090?heartbeat_interval=garbage")]
+    [InlineData("iggy://iggy:hunter2@localhost:70000")]
+    [InlineData("iggy+quic://iggy:hunter2@localhost:8090")]
+    public void FromConnectionString_ErrorMessages_NeverContainCredentials(string connectionString)
+    {
+        var exception = Assert.Throws<FormatException>(
+            () => IggyClientConfigurator.FromConnectionString(connectionString));
+
+        Assert.DoesNotContain("hunter2", exception.Message);
+        Assert.DoesNotContain("iggypat-1234567890abcdef", exception.Message);
+        Assert.DoesNotContain("hunter2", exception.InnerException?.Message ?? string.Empty);
+    }
+
+    [Fact]
+    public void FromConnectionString_ReturnsAConfiguratorTheCallerCanStillAdjust()
+    {
+        var config = IggyClientConfigurator.FromConnectionString("iggy://iggy:secret@localhost:8090");
+        config.ReceiveBufferSize = 4096;
+
+        Assert.Equal(4096, config.ReceiveBufferSize);
+        Assert.Equal(64 * 1024 * 1024, config.MaxResponseFrameSize);
+        Assert.Null(config.MessageEncryptor);
+    }
+
+    [Fact]
+    public void CreateClient_FromConnectionString_CreatesTcpClient()
+    {
+        using var client = IggyClientFactory.CreateClient("iggy://iggy:secret@127.0.0.1:8090") as IDisposable;
+
+        Assert.NotNull(client);
+    }
+
+    [Fact]
+    public void CreateClient_FromMalformedConnectionString_Throws()
+    {
+        Assert.Throws<FormatException>(() => IggyClientFactory.CreateClient("iggy://iggy:secret@127.0.0.1"));
+    }
+
+    [Theory]
+    [InlineData("500us")]
+    [InlineData("50d")]
+    public void CreateClient_FromConnectionString_ValidatesHeartbeatRange(string interval)
+    {
+        Assert.Throws<FormatException>(() => IggyClientFactory.CreateClient(
+            $"iggy://iggy:secret@127.0.0.1:8090?heartbeat_interval={interval}"));
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs
new file mode 100644
index 000000000..1b25169dd
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs
@@ -0,0 +1,227 @@
+// 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.
+
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Tests.UtilityTests;
+
+public sealed class IggyDurationParserTests
+{
+    private const string OverflowMessage =
+        "number is too large or cannot be represented without a lack of precision (values below 1ns are not supported)";
+
+    private const string SupportedUnits =
+        "supported units: ns, us/µs, ms, sec, min, hours, days, weeks, months, years (and few variations)";
+
+    [Theory]
+    [InlineData("17nsec", 0UL, 17U)]
+    [InlineData("17nanos", 0UL, 17U)]
+    [InlineData("33ns", 0UL, 33U)]
+    [InlineData("3usec", 0UL, 3000U)]
+    [InlineData("78us", 0UL, 78_000U)]
+    [InlineData("163µs", 0UL, 163_000U)]
+    [InlineData("31msec", 0UL, 31_000_000U)]
+    [InlineData("31millis", 0UL, 31_000_000U)]
+    [InlineData("6ms", 0UL, 6_000_000U)]
+    [InlineData("3000s", 3000UL, 0U)]
+    [InlineData("300secs", 300UL, 0U)]
+    [InlineData("50seconds", 50UL, 0U)]
+    [InlineData("1second", 1UL, 0U)]
+    [InlineData("100m", 6000UL, 0U)]
+    [InlineData("12mins", 720UL, 0U)]
+    [InlineData("1min", 60UL, 0U)]
+    [InlineData("7minutes", 420UL, 0U)]
+    [InlineData("1minute", 60UL, 0U)]
+    [InlineData("2h", 7200UL, 0U)]
+    [InlineData("1hr", 3600UL, 0U)]
+    [InlineData("7hrs", 25_200UL, 0U)]
+    [InlineData("1hour", 3600UL, 0U)]
+    [InlineData("24hours", 86_400UL, 0U)]
+    [InlineData("1day", 86_400UL, 0U)]
+    [InlineData("2days", 172_800UL, 0U)]
+    [InlineData("365d", 31_536_000UL, 0U)]
+    [InlineData("1week", 604_800UL, 0U)]
+    [InlineData("7weeks", 4_233_600UL, 0U)]
+    [InlineData("1wk", 604_800UL, 0U)]
+    [InlineData("104wks", 62_899_200UL, 0U)]
+    [InlineData("52w", 31_449_600UL, 0U)]
+    [InlineData("1month", 2_630_016UL, 0U)]
+    [InlineData("3months", 3 * 2_630_016UL, 0U)]
+    [InlineData("1year", 31_557_600UL, 0U)]
+    [InlineData("15yrs", 15 * 31_557_600UL, 0U)]
+    [InlineData("10yr", 10 * 31_557_600UL, 0U)]
+    [InlineData("17y", 536_479_200UL, 0U)]
+    public void ParseHumantime_ParsesEveryUnitSpelling(string input, ulong seconds, uint nanoseconds)
+    {
+        Assert.Equal((seconds, nanoseconds), IggyDurationParser.ParseHumantime(input));
+    }
+
+    [Theory]
+    [InlineData("2h 37min", 9420UL, 0U)]
+    [InlineData("2h 15m", 8100UL, 0U)]
+    [InlineData("1h 1m 1s", 3661UL, 0U)]
+    [InlineData("1h30m", 5400UL, 0U)]
+    [InlineData("20 min 17 nsec ", 1200UL, 17U)]
+    [InlineData("  5s", 5UL, 0U)]
+    [InlineData("1.234s0.345ms0.678us0ns", 1UL, 234_345_678U)]
+    [InlineData("1.234s 1.345ms 1.678us 1ns", 1UL, 235_346_679U)]
+    [InlineData("999999999ns 1ns", 1UL, 0U)]
+    public void ParseHumantime_CombinesTokensWithAndWithoutWhitespace(string input, ulong seconds,
+        uint nanoseconds)
+    {
+        Assert.Equal((seconds, nanoseconds), IggyDurationParser.ParseHumantime(input));
+    }
+
+    [Theory]
+    [InlineData("4.2s", 4UL, 200_000_000U)]
+    [InlineData("1.5minute", 90UL, 0U)]
+    [InlineData("0.01m", 0UL, 600_000_000U)]
+    [InlineData("0.5h", 1800UL, 0U)]
+    [InlineData("1.123456789s", 1UL, 123_456_789U)]
+    [InlineData("31.000001ms", 0UL, 31_000_001U)]
+    [InlineData("1.5us", 0UL, 1500U)]
+    public void ParseHumantime_SupportsExactFractions(string input, ulong seconds, uint nanoseconds)
+    {
+        Assert.Equal((seconds, nanoseconds), IggyDurationParser.ParseHumantime(input));
+    }
+
+    [Theory]
+    [InlineData("0.000123456789s")]
+    [InlineData("31.0000001ms")]
+    [InlineData("1.0000000002s")]
+    [InlineData("0.0000000002s")]
+    [InlineData("1.5ns")]
+    public void ParseHumantime_RejectsPrecisionLossAsOverflow(string input)
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyDurationParser.ParseHumantime(input));
+
+        Assert.Equal(OverflowMessage, exception.Message);
+    }
+
+    [Theory]
+    [InlineData("100000000000000000000ns")]
+    [InlineData("100000000000000ms")]
+    [InlineData("10000000000000000000m")]
+    [InlineData("100000000000000000d")]
+    [InlineData("10000000000000y")]
+    public void ParseHumantime_ReportsU64Overflow(string input)
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyDurationParser.ParseHumantime(input));
+
+        Assert.Equal(OverflowMessage, exception.Message);
+    }
+
+    [Theory]
+    [InlineData("1.s", "invalid character at 1")]
+    [InlineData("1..s", "invalid character at 1")]
+    [InlineData(".1s", "expected number at 0")]
+    [InlineData(".", "expected number at 0")]
+    public void ParseHumantime_RejectsMalformedFractions(string input, string message)
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyDurationParser.ParseHumantime(input));
+
+        Assert.Equal(message, exception.Message);
+    }
+
+    [Theory]
+    [InlineData("123", "time unit needed, for example 123sec or 123ms")]
+    [InlineData("10 months 1", "time unit needed, for example 1sec or 1ms")]
+    [InlineData("10nights", "unknown time unit \"nights\", " + SupportedUnits)]
+    [InlineData("222nsec221nanosmsec7s5msec572s", "unknown time unit \"nanosmsec\", " + SupportedUnits)]
+    [InlineData("\0", "expected number at 0")]
+    [InlineData("\r", "value was empty")]
+    [InlineData("", "value was empty")]
+    [InlineData("1~", "invalid character at 1")]
+    [InlineData("1nå", "invalid character at 2")]
+    [InlineData("1N", "invalid character at 1")]
+    [InlineData("1µå", "invalid character at 3")]
+    public void ParseHumantime_ProducesErrorMessages(string input, string message)
+    {
+        var exception = Assert.Throws<FormatException>(() => IggyDurationParser.ParseHumantime(input));
+
+        Assert.Equal(message, exception.Message);
+    }
+
+    [Theory]
+    [InlineData("500ms", 500)]
+    [InlineData("5s", 5000)]
+    [InlineData("2m", 120_000)]
+    [InlineData("1h", 3_600_000)]
+    [InlineData("0.5s", 500)]
+    [InlineData("1h 1m 1s", 3_661_000)]
+    [InlineData("1h30m", 5_400_000)]
+    [InlineData("5d", 432_000_000)]
+    [InlineData("2w", 1_209_600_000)]
+    [InlineData("1y", 31_557_600_000)]
+    [InlineData("5sec", 5000)]
+    [InlineData("5msec", 5)]
+    [InlineData("1.005s", 1005)]
+    [InlineData("0s", 0)]
+    public void Parse_ConvertsToTimeSpan(string input, long milliseconds)
+    {
+        Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), IggyDurationParser.Parse(input));
+    }
+
+    [Fact]
+    public void Parse_KeepsSubMillisecondPrecisionDownToOneTick()
+    {
+        Assert.Equal(TimeSpan.FromMicroseconds(5), IggyDurationParser.Parse("5usec"));
+        Assert.Equal(TimeSpan.FromTicks(5), IggyDurationParser.Parse("500nsec"));
+        Assert.Equal(TimeSpan.Zero, IggyDurationParser.Parse("17ns"));
+    }
+
+    [Theory]
+    [InlineData("0")]
+    [InlineData("unlimited")]
+    [InlineData("disabled")]
+    [InlineData("none")]
+    [InlineData("UNLIMITED")]
+    [InlineData("None")]
+    [InlineData("Disabled")]
+    public void Parse_MapsZeroSpellingsToZeroCaseInsensitively(string input)
+    {
+        Assert.Equal(TimeSpan.Zero, IggyDurationParser.Parse(input));
+    }
+
+    [Fact]
+    public void Parse_IsCaseInsensitive()
+    {
+        Assert.Equal(TimeSpan.FromSeconds(3661), IggyDurationParser.Parse("1H 1M 1S"));
+    }
+
+    [Theory]
+    [InlineData("5")]
+    [InlineData("-1s")]
+    [InlineData("ms")]
+    [InlineData("")]
+    [InlineData("abc")]
+    [InlineData("s")]
+    [InlineData("1 hour and 30 minutes")]
+    public void Parse_RejectsInvalidDurations(string input)
+    {
+        Assert.Throws<FormatException>(() => IggyDurationParser.Parse(input));
+    }
+
+    [Fact]
+    public void Parse_RejectsDurationsBeyondTimeSpan()
+    {
+        // Past the ~29,000 years a TimeSpan covers.
+        var exception = Assert.Throws<FormatException>(() => IggyDurationParser.Parse("100000y"));
+
+        Assert.Equal(OverflowMessage, exception.Message);
+    }
+}
diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md
index 41feb6919..98de470a7 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -148,6 +148,50 @@ await client.ConnectAsync();
 TCP applies the connection, heartbeat, TLS, and auto-login settings. HTTP `ConnectAsync` does no
 network work and requires explicit login. For TLS configuration, see the [TcpTls example](../../examples/csharp/README.md#tcptls).
 
+### Connection String
+
+A TCP client can also be created from the connection string format shared with the other SDKs. The
+credentials become the auto-login settings, so the client signs in on `ConnectAsync`:
+
+```c#
+using var client = IggyClientFactory.CreateClient("iggy://iggy:iggy@127.0.0.1:8090");
+await client.ConnectAsync();
+
+// A personal access token instead of a username and password, plus options
+using var tokenClient = IggyClientFactory.CreateClient(
+    "iggy+tcp://iggypat-your-token@127.0.0.1:8090?heartbeat_interval=10s&reconnection_retries=5");
+
+// Parse into a configurator to adjust the remaining settings
+var config = IggyClientConfigurator.FromConnectionString("iggy://iggy:iggy@127.0.0.1:8090");
+config.LoggerFactory = loggerFactory;
+```
+
+| Option                  | Default     | Description                                                                    |
+|-------------------------|-------------|--------------------------------------------------------------------------------|
+| `tls`                   | `false`     | `true` or `false`                                                              |
+| `tls_domain`            | server host | Server name for the TLS handshake, used for every node the client dials        |
+| `tls_ca_file`           | empty       | CA certificate path, read when connecting, required with `tls=true`            |
+| `reconnection_retries`  | `unlimited` | Count or `unlimited`. `0` turns reconnection off, also after a lost connection |
+| `reconnection_interval` | `1s`        | Fixed delay between attempts and before the first redial, at least `1ms`       |
+| `heartbeat_interval`    | `5s`        | Between `1ms` and about 49 days                                                |
+| `reestablish_after`     | -           | Validated, then ignored. `reconnection_interval` paces the first redial        |
+| `nodelay`               | -           | Validated, then ignored. The client always disables Nagle                      |
+
+`reestablish_after` and `nodelay` are accepted so that strings shared with other SDKs keep parsing, but
+they change nothing. The client has no `reestablish_after` window after a lost connection, and it always opens
+sockets with `NoDelay`, so `nodelay=false` does not turn Nagle back on even though the Rust and Node.js SDKs leave
+Nagle on by default.
+
+`reconnection_retries=0` differs from the Rust SDK. After a lost connection, Rust still makes one reconnect
+attempt, but this client fails the request at once.
+
+Reconnection uses no exponential backoff, and after each reconnect the client waits
+`ReconnectionSettings.WaitAfterReconnect` (1 second by default). Credentials are taken literally: they are
+not percent-decoded and must not contain `@` or `:`. Bracketed IPv6 hosts such as
+`iggy://iggy:iggy@[::1]:8090` are accepted. Durations look like `500ms`, `5s` or `1m30s`. Only `iggy://` and
+`iggy+tcp://` are supported. A malformed string throws `FormatException`, whose message never
+contains the connection string.
+
 ## Viewstamped Replication (VSR)
 
 Over TCP every request is wrapped in a 256-byte consensus header, the client registers a consensus session at