diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 51ce83bb10..ce5734750e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,6 +50,7 @@ This project includes several key products and libraries that facilitate SQL Ser - **Data Encryption**: Supports data encryption for secure data transmission. - **Logging and Diagnostics**: Provides event source tracing diagnostic capabilities for troubleshooting. - **Failover Support**: Handles automatic failover scenarios for high availability. + - Compatibility switch: `Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors` (default `false`) can restore legacy alternation behavior in `LoginWithFailover` for login-phase SQL errors. - **Cross-Platform Support**: Compatible with both .NET Framework and .NET Core, allowing applications to run on Windows, Linux, and macOS. - **Column Encryption AKV Provider**: Supports Azure Key Vault (AKV) provider for acquiring keys from Azure Key Vault to be used for encryption and decryption. diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md index 3ecba2cc4e..34262b8db6 100644 --- a/.github/instructions/features.instructions.md +++ b/.github/instructions/features.instructions.md @@ -246,6 +246,7 @@ AppContext switches allow runtime behavior changes without modifying connection | `Switch.Microsoft.Data.SqlClient.EnableMultiSubnetFailoverByDefault` | `false` | Sets `MultiSubnetFailover=true` as the default for all connections | | `Switch.Microsoft.Data.SqlClient.EnableUserAgent` | varies | Controls sending user agent information to SQL Server | | `Switch.Microsoft.Data.SqlClient.IgnoreServerProvidedFailoverPartner` | `false` | Ignores failover partner information sent by the server | +| `Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors` | `false` | Restores legacy `LoginWithFailover` alternation for login-phase SQL errors when parser state is not `Closed` | | `Switch.Microsoft.Data.SqlClient.LegacyRowVersionNullBehavior` | `false` | Restores legacy null handling for rowversion columns | | `Switch.Microsoft.Data.SqlClient.LegacyVarTimeZeroScaleBehaviour` | `false` | Restores legacy zero-scale behavior for time/datetime2/datetimeoffset | | `Switch.Microsoft.Data.SqlClient.MakeReadAsyncBlocking` | `false` | Makes ReadAsync behave synchronously (legacy compat) | diff --git a/AGENTS.md b/AGENTS.md index 42f3a39399..06bc0edfd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,10 +79,11 @@ Do **not** create branches directly under `main`, `dev/`, or any other top-level ### Bug Fix Workflow 1. Understand the issue from the bug report 2. Locate relevant code in `src/Microsoft.Data.SqlClient/src/` (do NOT modify legacy `netcore/src/` or `netfx/src/`) -3. Write a failing test that reproduces the issue -4. Implement the fix -5. Ensure all tests pass -6. Update documentation if behavior changes +3. Check `.github/instructions/features.instructions.md` for existing AppContext switches (including failover compatibility switches) before introducing behavior changes +4. Write a failing test that reproduces the issue +5. Implement the fix +6. Ensure all tests pass +7. Update documentation if behavior changes ### Feature Implementation 1. Review the feature specification diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs index e62541137c..6d067bab7f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs @@ -3635,7 +3635,17 @@ private void LoginWithFailover( continue; } - if (IsDoNotRetryConnectError(sqlex) || timeout.IsExpired) + bool isLoginPhaseSqlError = _parser?.State is not TdsParserState.Closed; + + // If state != closed, indicates that the parser encountered an error while + // processing the login response (e.g. an explicit error token). Transient + // network errors that impact connectivity will result in parser state being + // closed. Only network-level errors should trigger failover alternation; + // login-phase errors (like transient errors) should be thrown so they can + // be handled by the outer ConnectRetryCount loop. + if ((isLoginPhaseSqlError && !LocalAppContextSwitches.UseLegacyFailoverAlternationOnLoginSqlErrors) || + IsDoNotRetryConnectError(sqlex) || + timeout.IsExpired) { // No more time to try again. // Caller will call LoginFailure() diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs index 16e0abb7ec..0177338bf2 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -58,6 +58,13 @@ internal static class LocalAppContextSwitches private const string IgnoreServerProvidedFailoverPartnerString = "Switch.Microsoft.Data.SqlClient.IgnoreServerProvidedFailoverPartner"; + /// + /// The name of the app context switch that controls whether failover + /// alternation should use legacy behavior for login-phase SQL errors. + /// + private const string UseLegacyFailoverAlternationOnLoginSqlErrorsString = + "Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors"; + /// /// The name of the app context switch that controls whether to preserve /// legacy behavior where Timestamp/RowVersion fields return empty byte @@ -182,6 +189,11 @@ private enum SwitchValue : byte /// private static SwitchValue s_ignoreServerProvidedFailoverPartner = SwitchValue.None; + /// + /// The cached value of the UseLegacyFailoverAlternationOnLoginSqlErrors switch. + /// + private static SwitchValue s_useLegacyFailoverAlternationOnLoginSqlErrors = SwitchValue.None; + /// /// The cached value of the LegacyRowVersionNullBehavior switch. /// @@ -409,6 +421,19 @@ public static bool GlobalizationInvariantMode defaultValue: false, ref s_ignoreServerProvidedFailoverPartner); + /// + /// When set to true, LoginWithFailover preserves legacy behavior and may + /// alternate to the failover partner on login-phase SQL errors where the + /// parser state is not Closed. + /// + /// The default value of this switch is false. + /// + public static bool UseLegacyFailoverAlternationOnLoginSqlErrors => + AcquireAndReturn( + UseLegacyFailoverAlternationOnLoginSqlErrorsString, + defaultValue: false, + ref s_useLegacyFailoverAlternationOnLoginSqlErrors); + /// /// In System.Data.SqlClient and Microsoft.Data.SqlClient prior to 3.0.0 a /// field with type Timestamp/RowVersion would return an empty byte array. diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs index 8102f47d51..11523b3f83 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs @@ -47,6 +47,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable private readonly bool? _globalizationInvariantModeOriginal; #endif private readonly bool? _ignoreServerProvidedFailoverPartnerOriginal; + private readonly bool? _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal; private readonly bool? _legacyRowVersionNullBehaviorOriginal; private readonly bool? _legacyVarTimeZeroScaleBehaviourOriginal; private readonly bool? _makeReadAsyncBlockingOriginal; @@ -98,6 +99,8 @@ public LocalAppContextSwitchesHelper() #endif _ignoreServerProvidedFailoverPartnerOriginal = GetSwitchValue("s_ignoreServerProvidedFailoverPartner"); + _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal = + GetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors"); _legacyRowVersionNullBehaviorOriginal = GetSwitchValue("s_legacyRowVersionNullBehavior"); _legacyVarTimeZeroScaleBehaviourOriginal = @@ -154,6 +157,9 @@ public void Dispose() SetSwitchValue( "s_ignoreServerProvidedFailoverPartner", _ignoreServerProvidedFailoverPartnerOriginal); + SetSwitchValue( + "s_useLegacyFailoverAlternationOnLoginSqlErrors", + _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal); SetSwitchValue( "s_legacyRowVersionNullBehavior", _legacyRowVersionNullBehaviorOriginal); @@ -242,6 +248,15 @@ public bool? IgnoreServerProvidedFailoverPartner set => SetSwitchValue("s_ignoreServerProvidedFailoverPartner", value); } + /// + /// Get or set the UseLegacyFailoverAlternationOnLoginSqlErrors switch value. + /// + public bool? UseLegacyFailoverAlternationOnLoginSqlErrors + { + get => GetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors"); + set => SetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors", value); + } + /// /// Get or set the LegacyRowVersionNullBehavior switch value. /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs index a5db02faa0..a468d8fd37 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs @@ -28,6 +28,7 @@ public void TestDefaultAppContextSwitchValues() Assert.False(LocalAppContextSwitches.UseConnectionPoolV2); Assert.False(LocalAppContextSwitches.TruncateScaledDecimal); Assert.False(LocalAppContextSwitches.IgnoreServerProvidedFailoverPartner); + Assert.False(LocalAppContextSwitches.UseLegacyFailoverAlternationOnLoginSqlErrors); Assert.False(LocalAppContextSwitches.EnableMultiSubnetFailoverByDefault); #if NET Assert.False(LocalAppContextSwitches.GlobalizationInvariantMode); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs index d3a0bcd07b..1adc4dd6e4 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs @@ -4,6 +4,7 @@ using System; using System.Data; +using System.Threading.Tasks; using Microsoft.Data.SqlClient.Connection; using Microsoft.Data.SqlClient.Tests.Common; using Microsoft.SqlServer.TDS.Servers; @@ -172,7 +173,7 @@ public void NetworkTimeout_ShouldFail() InitialCatalog = "master",// Required for failover partner to work ConnectTimeout = 1, ConnectRetryInterval = 1, - ConnectRetryCount = 0, // Disable retry + ConnectRetryCount = 0, // Disable retry Encrypt = false, MultiSubnetFailover = false, #if NETFRAMEWORK @@ -336,6 +337,10 @@ public void NetworkError_WithUserProvidedPartner_RetryEnabled_ShouldConnectToFai Assert.Equal(1, failoverServer.PreLoginCount - failoverServer.AbandonedPreLoginCount); } + /// + /// Verifies login-phase transient SQL errors are retried on the primary endpoint and + /// do not trigger failover-partner alternation. + /// [Theory] [InlineData(40613)] [InlineData(42108)] @@ -372,6 +377,8 @@ public void TransientFault_ShouldConnectToPrimary(uint errorCode) using SqlConnection connection = new(builder.ConnectionString); // Act + // First login receives the transient token; outer connect retry opens a fresh parser + // and retries against the same primary endpoint. connection.Open(); // Assert @@ -380,6 +387,8 @@ public void TransientFault_ShouldConnectToPrimary(uint errorCode) // Failures should prompt the client to return to the original server, resulting in a login count of 2 Assert.Equal(2, server.PreLoginCount - server.AbandonedPreLoginCount); + // Login-phase errors must NOT trigger failover alternation + Assert.Equal(0, failoverServer.PreLoginCount); } [Theory] @@ -430,6 +439,10 @@ public void TransientFault_RetryDisabled_ShouldFail(uint errorCode) Assert.Fail(); } + /// + /// Verifies user-provided failover partner does not change behavior for login-phase + /// transient SQL errors; retries stay on primary. + /// [Theory] [InlineData(40613)] [InlineData(42108)] @@ -467,6 +480,8 @@ public void TransientFault_WithUserProvidedPartner_ShouldConnectToPrimary(uint e using SqlConnection connection = new(builder.ConnectionString); // Act + // Even with a configured partner, this path should use outer connect retry + // against primary rather than alternation inside LoginWithFailover. connection.Open(); // Assert @@ -475,6 +490,8 @@ public void TransientFault_WithUserProvidedPartner_ShouldConnectToPrimary(uint e // Failures should prompt the client to return to the original server, resulting in a login count of 2 Assert.Equal(2, server.PreLoginCount - server.AbandonedPreLoginCount); + // Login-phase errors must NOT trigger failover alternation + Assert.Equal(0, failoverServer.PreLoginCount); } [Theory] @@ -591,5 +608,328 @@ public void TransientFault_IgnoreServerProvidedFailoverPartner_ShouldConnectToUs // 1 for the failover connection Assert.Equal(1, failoverServer.PreLoginCount - failoverServer.AbandonedPreLoginCount); } + + /// + /// Async parity for primary-only retry behavior on login-phase transient SQL errors. + /// + [Theory] + [InlineData(40613)] + [InlineData(42108)] + [InlineData(42109)] + public async Task TransientFault_Async_ShouldConnectToPrimary_NotFailover(uint errorCode) + { + // Async parity for TransientFault_ShouldConnectToPrimary. + // A transient login-token error must be retried against the primary; + // the failover partner must never be contacted. + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + IsEnabledTransientError = true, + Number = errorCode, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + Encrypt = false, + Pooling = false, + }; + using SqlConnection connection = new(builder.ConnectionString); + + // Asserts async open follows the same retry and failover-selection rules as sync. + await connection.OpenAsync(); + + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal($"localhost,{server.EndPoint.Port}", connection.DataSource); + Assert.Equal(2, server.PreLoginCount - server.AbandonedPreLoginCount); + // Login-phase errors must NOT trigger failover alternation + Assert.Equal(0, failoverServer.PreLoginCount); + } + + /// + /// Async parity with user-provided partner: login-phase transient SQL errors should + /// still retry on primary without failover alternation. + /// + [Theory] + [InlineData(40613)] + [InlineData(42108)] + [InlineData(42109)] + public async Task TransientFault_WithUserProvidedPartner_Async_ShouldConnectToPrimary_NotFailover(uint errorCode) + { + // Async parity for TransientFault_WithUserProvidedPartner_ShouldConnectToPrimary. + // Even with a user-provided failover partner, a login-token error must not + // cause alternation to the failover server. + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + IsEnabledTransientError = true, + Number = errorCode, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + Encrypt = false, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }; + using SqlConnection connection = new(builder.ConnectionString); + + // Asserts async open with explicit partner still avoids failover alternation. + await connection.OpenAsync(); + + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal($"localhost,{server.EndPoint.Port}", connection.DataSource); + Assert.Equal(2, server.PreLoginCount - server.AbandonedPreLoginCount); + // Login-phase errors must NOT trigger failover alternation + Assert.Equal(0, failoverServer.PreLoginCount); + } + + /// + /// Verifies pooled connections are not cleared and failover is not attempted when a + /// login-phase transient SQL error occurs with a user-provided failover partner. + /// + [Theory] + [InlineData(40613)] + [InlineData(42108)] + [InlineData(42109)] + public void TransientFault_WithUserProvidedPartner_Pooling_ShouldNotClearPool_NotFailover(uint errorCode) + { + // With pooling enabled and a user-provided failover partner, a transient + // login-token error must not clear the pool and must not contact the failover server. + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + // Start with errors disabled so the pool warms up successfully. + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + Encrypt = SqlConnectionEncryptOption.Optional, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + Pooling = true, + }; + + // Keep one connection open so the next Open() cannot reuse it and must perform login. + using SqlConnection warmup = new(builder.ConnectionString); + warmup.Open(); + + // Enable the transient error for the next login attempt. + server.SetErrorBehavior(true, errorCode); + + // ConnectRetryCount > 0 (default 1) so the client retries and succeeds. + using SqlConnection connection = new(builder.ConnectionString); + connection.Open(); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal($"localhost,{server.EndPoint.Port}", connection.DataSource); + + connection.Close(); + warmup.Close(); + + // If the pool is not cleared, this open should reuse a pooled connection without a new login. + using SqlConnection pooledConnection = new(builder.ConnectionString); + pooledConnection.Open(); + + Assert.Equal(ConnectionState.Open, pooledConnection.State); + Assert.Equal($"localhost,{server.EndPoint.Port}", pooledConnection.DataSource); + + // 1 warmup login + 1 failed login + 1 retry login. + Assert.Equal(3, server.PreLoginCount - server.AbandonedPreLoginCount); + Assert.Equal(3, server.Login7Count); + // Failover server must never have been contacted. + Assert.Equal(0, failoverServer.PreLoginCount - failoverServer.AbandonedPreLoginCount); + Assert.Equal(0, failoverServer.Login7Count); + } + + /// + /// Verifies ConnectRetryCount=0 propagates login-phase transient SQL errors immediately + /// and never attempts failover alternation. + /// + [Theory] + [InlineData(40613)] + [InlineData(42108)] + [InlineData(42109)] + public void TransientFault_RetryDisabled_WithUserProvidedPartner_ShouldFail_NotFailover(uint errorCode) + { + // When ConnectRetryCount = 0 and the server returns a login-phase token error, + // the exception must propagate immediately and the failover partner must not be + // contacted (parser state is not Closed, so the new guard must kick in). + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + IsEnabledTransientError = true, + Number = errorCode, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + ConnectRetryCount = 0, + Encrypt = false, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }; + using SqlConnection connection = new(builder.ConnectionString); + + // No outer connect retry is allowed, so the first transient error should surface. + SqlException ex = Assert.Throws(() => connection.Open()); + + Assert.Equal((int)errorCode, ex.Number); + Assert.Equal(ConnectionState.Closed, connection.State); + // The parser was not closed (login-phase error), so the failover alternation branch + // must not have been entered. + Assert.Equal(0, failoverServer.PreLoginCount); + } + + /// + /// Isolates the parser-state guard by using a non-fatal login error token: without the + /// guard, LoginWithFailover alternates to partner; with the guard, retry stays on primary. + /// + [Fact] + public void NonFatalTransientLoginError_WithUserProvidedPartner_ShouldRetryPrimary_NotFailover() + { + // This test isolates the parser-state guard added to LoginWithFailover. + // We emit a transient login error with non-fatal severity so the connection + // is not automatically doomed/broken by existing breakConnection logic. + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + IsEnabledTransientError = true, + Number = 40613, + // Use non-fatal severity so break/doom logic does not short-circuit the path. + ErrorClass = 16, + RepeatCount = 1, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + Encrypt = false, + Pooling = false, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }; + + using SqlConnection connection = new(builder.ConnectionString); + connection.Open(); + + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal($"localhost,{server.EndPoint.Port}", connection.DataSource); + Assert.Equal(2, server.PreLoginCount - server.AbandonedPreLoginCount); + Assert.Equal(0, failoverServer.PreLoginCount - failoverServer.AbandonedPreLoginCount); + } + + /// + /// Verifies opt-in legacy behavior: login-phase SQL errors can alternate to the + /// failover partner when UseLegacyFailoverAlternationOnLoginSqlErrors is enabled. + /// + [Fact] + public void NonFatalTransientLoginError_WithLegacySwitch_ShouldAlternateToFailoverPartner() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyFailoverAlternationOnLoginSqlErrors = true; + + using TdsServer failoverServer = new( + new TdsServerArguments + { + FailoverPartner = "localhost,1234", + }); + failoverServer.Start(); + + using TransientTdsErrorTdsServer server = new( + new TransientTdsErrorTdsServerArguments() + { + IsEnabledTransientError = true, + Number = 40613, + // Keep the login token non-fatal so parser state, not break/doom behavior, + // drives this branch decision. + ErrorClass = 16, + RepeatCount = 1, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }); + server.Start(); + + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{server.EndPoint.Port}", + InitialCatalog = "master", + ConnectTimeout = 30, + ConnectRetryInterval = 1, + Encrypt = false, + Pooling = false, + FailoverPartner = $"localhost,{failoverServer.EndPoint.Port}", + }; + + using SqlConnection connection = new(builder.ConnectionString); + connection.Open(); + + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal($"localhost,{failoverServer.EndPoint.Port}", connection.DataSource); + Assert.Equal(1, server.PreLoginCount - server.AbandonedPreLoginCount); + Assert.Equal(1, failoverServer.PreLoginCount - failoverServer.AbandonedPreLoginCount); + } } } diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServer.cs index 6ec7ac2528..ba20fb4d02 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServer.cs @@ -89,7 +89,7 @@ private TDSMessageCollection GenerateErrorMessage(TDSMessage request) TDSUtilities.Log(Arguments.Log, "Request", request); // Prepare ERROR token with the denial details - TDSErrorToken errorToken = new TDSErrorToken(errorNumber, 1, 20, errorMessage); + TDSErrorToken errorToken = new TDSErrorToken(errorNumber, 1, Arguments.ErrorClass, errorMessage); // Log response TDSUtilities.Log(Arguments.Log, "Response", errorToken); diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServerArguments.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServerArguments.cs index 6a61d9cc6f..5f1adacd61 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServerArguments.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TransientTdsErrorTdsServerArguments.cs @@ -25,5 +25,13 @@ public class TransientTdsErrorTdsServerArguments : TdsServerArguments /// The number of times the transient error should be raised. /// public int RepeatCount { get; set; } = 1; + + /// + /// Error class (severity) to emit in ERROR token. + /// The default is 20 to preserve existing fatal-error behavior. + /// Fatal starts at 20 (TdsEnums.FATAL_ERROR_CLASS), so set values below 20 + /// when a test needs to avoid automatic break/doom behavior in the client. + /// + public byte ErrorClass { get; set; } = 20; } }