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;
}
}