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

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 9536916bd feat(csharp): implement connection string parsing (#4363)
9536916bd is described below

commit 9536916bd0853129c2c89e9a09cd0aea49d2b618
Author: Łukasz Zborek <[email protected]>
AuthorDate: Sat Oct 10 14:17:39 2026 +0200

    feat(csharp): implement connection string parsing (#4363)
---
 .../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    | 394 ++++++++++++++++++
 .../ClientTests/ConnectionStringTests.cs           | 441 +++++++++++++++++++++
 .../UtilityTests/IggyDurationParserTests.cs        | 227 +++++++++++
 foreign/csharp/README.md                           |  45 +++
 9 files changed, 1532 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..8595574af
--- /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 = (int)Math.Min(retries, int.MaxValue);
+    }
+
+    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:[email protected]: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..3933803cd
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
@@ -0,0 +1,394 @@
+// 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;
+            while (Next() is { } c)
+            {
+                if (c == '0')
+                {
+                    denominator = Mul(denominator, 10);
+                    numerator = Mul(numerator, 10);
+                }
+                else if (IsDigit(c))
+                {
+                    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..46947ef01
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
@@ -0,0 +1,441 @@
+// 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:[email protected]:1234")]
+    [InlineData("iggy+tcp://user:[email protected]: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://[email protected]: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:[email protected]: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)]
+    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")]
+    [InlineData("2147483648")]
+    [InlineData("4294967295")]
+    public void 
FromConnectionString_WithRetriesAboveInt32Max_CapsAtInt32Max(string retries)
+    {
+        var config = IggyClientConfigurator.FromConnectionString(
+            
$"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}");
+
+        Assert.True(config.ReconnectionSettings.Enabled);
+        Assert.Equal(int.MaxValue, 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:[email protected]: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:[email protected]: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:[email protected]:1234")]
+    [InlineData("tcp://user:[email protected]:1234")]
+    [InlineData("iggy://:[email protected]:1234")]
+    [InlineData("iggy+tcp://:[email protected]: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:[email protected]:1234")]
+    [InlineData("iggy://user:[email protected]:1234@other")]
+    [InlineData("iggy://user:secret@:1234")]
+    [InlineData("iggy+tcp://user:secret@:1234")]
+    [InlineData("iggy://user:[email protected]:")]
+    [InlineData("iggy+tcp://user:[email protected]:")]
+    [InlineData("iggy://user:[email protected]")]
+    [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:[email protected]:1234?invalid_option=invalid")]
+    [InlineData("iggy+tcp://user:[email protected]:?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:[email protected]:8090") as 
IDisposable;
+
+        Assert.NotNull(client);
+    }
+
+    [Fact]
+    public void CreateClient_FromMalformedConnectionString_Throws()
+    {
+        Assert.Throws<FormatException>(() => 
IggyClientFactory.CreateClient("iggy://iggy:[email protected]"));
+    }
+
+    [Theory]
+    [InlineData("500us")]
+    [InlineData("50d")]
+    public void 
CreateClient_FromConnectionString_ValidatesHeartbeatRange(string interval)
+    {
+        Assert.Throws<FormatException>(() => IggyClientFactory.CreateClient(
+            
$"iggy://iggy:[email protected]: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 d2a9fb5d5..205f3dd4f 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -159,6 +159,51 @@ 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:[email protected]:8090");
+await client.ConnectAsync();
+
+// A personal access token instead of a username and password, plus options
+using var tokenClient = IggyClientFactory.CreateClient(
+    
"iggy+tcp://[email protected]:8090?heartbeat_interval=10s&reconnection_retries=5");
+
+// Parse into a configurator to adjust the remaining settings
+var config = 
IggyClientConfigurator.FromConnectionString("iggy://iggy:[email protected]: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. It also differs from 
`ReconnectionSettings.MaxRetries`,
+where `0` means unlimited, so the parser maps `reconnection_retries=0` to 
`Enabled = false` instead.
+
+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

Reply via email to