Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions sdk/cosmos/azure-cosmos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Release History

### 4.14.7 (Unreleased)

#### Bugs Fixed
* Fixed bug where region names in `preferred_locations` and `excluded_locations` (client-level and per-request) were matched case-sensitively and required exact spacing. See [PR 46792](https://github.com/Azure/azure-sdk-for-python/pull/46792)

### 4.14.6 (2026-02-02)

#### Bugs Fixed
Expand Down
111 changes: 95 additions & 16 deletions sdk/cosmos/azure-cosmos/azure/cosmos/_location_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@

logger = logging.getLogger("azure.cosmos.LocationCache")


def _normalize_region_name(region_name: str) -> str:
Comment thread
dibahlfi marked this conversation as resolved.
Outdated
if region_name is None:
return ""
normalized = "".join(str(region_name).strip().lower().split())
return normalized.replace("-", "").replace("_", "")

class EndpointOperationType(object):
NoneType = "None"
ReadType = "Read"
Expand Down Expand Up @@ -120,11 +127,16 @@ def _get_applicable_regional_routing_contexts(regional_routing_contexts: list[Re
:return: A filtered and reordered list of regional routing contexts.
:rtype: list[RegionalRoutingContext]
"""
normalized_excluded_locations = {_normalize_region_name(location) for location in exclude_location_list}
Comment thread
dibahlfi marked this conversation as resolved.
normalized_circuit_breaker_locations = {_normalize_region_name(location) for location in circuit_breaker_exclude_list}

# filter endpoints by excluded locations
applicable_regional_routing_contexts = []
user_excluded_regional_routing_contexts = []
for regional_routing_context in regional_routing_contexts:
if location_name_by_endpoint.get(regional_routing_context.get_primary()) not in exclude_location_list:
location_name = location_name_by_endpoint.get(regional_routing_context.get_primary())
normalized_location_name = _normalize_region_name(location_name)
if normalized_location_name not in normalized_excluded_locations:
applicable_regional_routing_contexts.append(regional_routing_context)
else:
user_excluded_regional_routing_contexts.append(regional_routing_context)
Expand All @@ -133,7 +145,9 @@ def _get_applicable_regional_routing_contexts(regional_routing_contexts: list[Re
final_applicable_contexts = []
circuit_breaker_excluded_contexts = []
for regional_routing_context in applicable_regional_routing_contexts:
if location_name_by_endpoint.get(regional_routing_context.get_primary()) in circuit_breaker_exclude_list:
location_name = location_name_by_endpoint.get(regional_routing_context.get_primary())
normalized_location_name = _normalize_region_name(location_name)
if normalized_location_name in normalized_circuit_breaker_locations:
circuit_breaker_excluded_contexts.append(regional_routing_context)
else:
final_applicable_contexts.append(regional_routing_context)
Expand Down Expand Up @@ -172,6 +186,7 @@ def __init__(
self.account_write_locations: list[str] = []
self.account_read_locations: list[str] = []
self.connection_policy: ConnectionPolicy = connection_policy
self._config_mismatch_warning_dedupe: set[tuple[str, tuple[str, ...], tuple[str, ...]]] = set()

def get_write_regional_routing_contexts(self):
return self.write_regional_routing_contexts
Expand Down Expand Up @@ -228,6 +243,34 @@ def _get_configured_excluded_locations(self, request: RequestObject) -> list[str

return excluded_locations

def _emit_config_mismatch_warning_once(self, configured_locations: list[str], available_locations: list[str], setting_name: str):
if not configured_locations:
return

available_by_normalized = {_normalize_region_name(location): location for location in available_locations}
unmatched_locations = [
location
for location in configured_locations
if _normalize_region_name(location) not in available_by_normalized
]

if unmatched_locations:
dedupe_key = (
setting_name,
tuple(sorted(_normalize_region_name(location) for location in unmatched_locations)),
tuple(sorted(available_by_normalized.keys())),
)
if dedupe_key in self._config_mismatch_warning_dedupe:
return
self._config_mismatch_warning_dedupe.add(dedupe_key)

logger.warning(
"Ignoring %s entries that did not match account regions: %s. Available regions: %s",
setting_name,
unmatched_locations,
available_locations,
)

def _get_applicable_read_regional_routing_contexts(self, request: RequestObject) -> list[RegionalRoutingContext]:
# Get configured excluded locations
excluded_locations = self._get_configured_excluded_locations(request)
Expand Down Expand Up @@ -298,14 +341,20 @@ def _resolve_endpoint_without_preferred_locations(self, request, is_write, locat
excluded_locations = self._get_configured_excluded_locations(request)
circuit_breaker_excluded_locations = request.excluded_locations_circuit_breaker or []

normalized_excluded_locations = {_normalize_region_name(location) for location in excluded_locations}
normalized_circuit_breaker_locations = {
_normalize_region_name(location) for location in circuit_breaker_excluded_locations
}

applicable_contexts = []
circuit_breaker_contexts = []
for loc_name in ordered_locations:
if loc_name in all_contexts_by_loc:
context = all_contexts_by_loc[loc_name]
if loc_name in excluded_locations:
normalized_location_name = _normalize_region_name(loc_name)
if normalized_location_name in normalized_excluded_locations:
continue # Skip user-excluded locations
if loc_name in circuit_breaker_excluded_locations:
if normalized_location_name in normalized_circuit_breaker_locations:
circuit_breaker_contexts.append(context)
else:
applicable_contexts.append(context)
Expand Down Expand Up @@ -365,19 +414,27 @@ def resolve_service_endpoint(self, request):

def should_refresh_endpoints(self): # pylint: disable=too-many-return-statements
most_preferred_location = self.effective_preferred_locations[0] if self.effective_preferred_locations else None
normalized_most_preferred_location = _normalize_region_name(most_preferred_location) if most_preferred_location else None
read_locations_by_normalized = {
_normalize_region_name(name): endpoint
for name, endpoint in self.account_read_regional_routing_contexts_by_location.items()
}
write_locations_by_normalized = {
_normalize_region_name(name): endpoint
for name, endpoint in self.account_write_regional_routing_contexts_by_location.items()
}

# we should schedule refresh in background if we are unable to target the user's most preferredLocation.
if self.connection_policy.EnableEndpointDiscovery:

should_refresh = (self.connection_policy.UseMultipleWriteLocations
and not self.enable_multiple_writable_locations)

if (most_preferred_location and most_preferred_location in
self.account_read_regional_routing_contexts_by_location):
if (self.account_read_regional_routing_contexts_by_location
and most_preferred_location in self.account_read_regional_routing_contexts_by_location):
most_preferred_read_endpoint = (
self.account_read_regional_routing_contexts_by_location)[most_preferred_location]
if (normalized_most_preferred_location and normalized_most_preferred_location in
Comment thread
dibahlfi marked this conversation as resolved.
read_locations_by_normalized):
if (read_locations_by_normalized
and normalized_most_preferred_location in read_locations_by_normalized):
most_preferred_read_endpoint = read_locations_by_normalized[normalized_most_preferred_location]
if (most_preferred_read_endpoint and
most_preferred_read_endpoint != self.read_regional_routing_contexts[0]):
# For reads, we can always refresh in background as we can alternate to
Expand All @@ -394,10 +451,9 @@ def should_refresh_endpoints(self): # pylint: disable=too-many-return-statement
# we have an alternate write endpoint
return True
return should_refresh
if (most_preferred_location and
most_preferred_location in self.account_write_regional_routing_contexts_by_location):
most_preferred_write_regional_endpoint = (
self.account_write_regional_routing_contexts_by_location)[most_preferred_location]
if (normalized_most_preferred_location and
normalized_most_preferred_location in write_locations_by_normalized):
most_preferred_write_regional_endpoint = write_locations_by_normalized[normalized_most_preferred_location]
if most_preferred_write_regional_endpoint:
should_refresh |= most_preferred_write_regional_endpoint != self.write_regional_routing_contexts[0]
return should_refresh
Expand Down Expand Up @@ -487,6 +543,22 @@ def update_location_cache(self, write_locations=None, read_locations=None, enabl
self.write_regional_routing_contexts[0]
)

# Config-time visibility for misconfigured region names. Dedupe ensures periodic
# refreshes do not re-emit identical warnings; new mismatches still surface because
# the dedupe key includes the available account regions snapshot.
if self.connection_policy.PreferredLocations:
self._emit_config_mismatch_warning_once(
self.connection_policy.PreferredLocations,
self.account_read_locations or self.account_write_locations,
"preferred_locations",
)
if self.connection_policy.ExcludedLocations:
self._emit_config_mismatch_warning_once(
list(self.connection_policy.ExcludedLocations),
self.account_read_locations or self.account_write_locations,
"excluded_locations",
)

def get_preferred_regional_routing_contexts(
self, endpoints_by_location, orderedLocations, expected_available_operation, fallback_endpoint
):
Expand All @@ -500,12 +572,18 @@ def get_preferred_regional_routing_contexts(
):
unavailable_endpoints = []
if self.effective_preferred_locations:
endpoints_by_normalized_location = {
_normalize_region_name(location): endpoint
for location, endpoint in endpoints_by_location.items()
}

# When client can not use multiple write locations, preferred locations
# list should only be used determining read endpoints order. If client
# can use multiple write locations, preferred locations list should be
# used for determining both read and write endpoints order.
for location in self.effective_preferred_locations:
regional_endpoint = endpoints_by_location.get(location)
normalized_location = _normalize_region_name(location)
regional_endpoint = endpoints_by_normalized_location.get(normalized_location)
if regional_endpoint:
if self.is_endpoint_unavailable(regional_endpoint.get_primary(),
expected_available_operation):
Expand Down Expand Up @@ -579,7 +657,8 @@ def GetLocationalEndpoint(default_endpoint, location_name):
global_database_account_name = hostname_parts[0]

# Prepare the locational_database_account_name as contoso-eastus for location_name 'east us'
locational_database_account_name = global_database_account_name + "-" + location_name.replace(" ", "")
normalized_location_name = _normalize_region_name(location_name)
locational_database_account_name = global_database_account_name + "-" + normalized_location_name
locational_database_account_name = locational_database_account_name.lower()
Comment thread
dibahlfi marked this conversation as resolved.
Outdated

# Replace 'contoso' with 'contoso-eastus' and return locational_endpoint
Expand Down
104 changes: 103 additions & 1 deletion sdk/cosmos/azure-cosmos/tests/test_location_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
location4_endpoint = "https://location4.documents.azure.com"
refresh_time_interval_in_ms = 1000

canonical_location1_name = "East US 2"
canonical_location2_name = "West US 3"
canonical_location1_endpoint = "https://eastus2.documents.azure.com"
canonical_location2_endpoint = "https://westus3.documents.azure.com"


def create_database_account(enable_multiple_writable_locations):
db_acc = DatabaseAccount()
Expand All @@ -46,6 +51,20 @@ def refresh_location_cache(preferred_locations, use_multiple_write_locations, co
connection_policy=connection_policy)
return lc


def create_database_account_with_canonical_regions(enable_multiple_writable_locations):
db_acc = DatabaseAccount()
db_acc._WritableLocations = [
{"name": canonical_location1_name, "databaseAccountEndpoint": canonical_location1_endpoint},
{"name": canonical_location2_name, "databaseAccountEndpoint": canonical_location2_endpoint},
]
db_acc._ReadableLocations = [
{"name": canonical_location1_name, "databaseAccountEndpoint": canonical_location1_endpoint},
{"name": canonical_location2_name, "databaseAccountEndpoint": canonical_location2_endpoint},
]
db_acc._EnableMultipleWritableLocations = enable_multiple_writable_locations
return db_acc

@pytest.mark.cosmosEmulator
class TestLocationCache:

Expand Down Expand Up @@ -472,5 +491,88 @@ def test_write_fallback_to_global_after_regional_retries_exhausted(self):
final_endpoint = lc.resolve_service_endpoint(write_request)
assert final_endpoint == location1_endpoint

def test_preferred_locations_support_normalized_region_names(self):
# Preferred locations should match account region names even with case/spacing/separator variations.
lc = refresh_location_cache(["east-us-2", " west_us_3 "], True)
db_acc = create_database_account_with_canonical_regions(enable_multiple_writable_locations=True)
lc.perform_on_database_account_read(db_acc)

write_contexts = lc.get_write_regional_routing_contexts()
read_contexts = lc.get_read_regional_routing_contexts()

assert write_contexts[0].get_primary() == canonical_location1_endpoint
assert write_contexts[1].get_primary() == canonical_location2_endpoint
assert read_contexts[0].get_primary() == canonical_location1_endpoint
assert read_contexts[1].get_primary() == canonical_location2_endpoint

def test_excluded_locations_support_normalized_region_names(self):
Comment thread
dibahlfi marked this conversation as resolved.
# Excluded locations should filter regions even when normalized names are used.
connection_policy = documents.ConnectionPolicy()
connection_policy.ExcludedLocations = ["east-us-2"]

lc = refresh_location_cache([canonical_location1_name, canonical_location2_name], True, connection_policy)
db_acc = create_database_account_with_canonical_regions(enable_multiple_writable_locations=True)
lc.perform_on_database_account_read(db_acc)

read_request = RequestObject(ResourceType.Document, _OperationType.Read, None)
write_request = RequestObject(ResourceType.Document, _OperationType.Create, None)
write_request.excluded_locations = ["west_us_3"]

assert lc.resolve_service_endpoint(read_request) == canonical_location2_endpoint
assert lc.resolve_service_endpoint(write_request) == canonical_location1_endpoint

def test_should_refresh_endpoints_handles_normalized_preferred_region(self):
# should_refresh_endpoints must match canonical region keys even when the
# customer's preferred location uses non-canonical spelling.
lc = refresh_location_cache(["east-us-2"], True)
db_acc = create_database_account_with_canonical_regions(enable_multiple_writable_locations=True)
lc.perform_on_database_account_read(db_acc)

# Most-preferred is already the primary; no background refresh should be triggered.
assert lc.should_refresh_endpoints() is False

def test_get_locational_endpoint_normalizes_customer_region_string(self):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Recommendation — Test Coverage: Missing collision-safety test for normalization invariant

The PR description explicitly notes: "a more aggressive rule could collapse genuinely different regions like 'East US' and 'East US 2' into the same key." This is the critical design invariant — but no test guards it.

All normalization tests use "East US 2" in different spellings. None verify that structurally similar but distinct regions (e.g., "East US" vs "East US 2", "West US" vs "West US 2" vs "West US 3") remain distinct after normalization. If a future refactor of _normalize_region_name accidentally strips digits, traffic could silently route to the wrong region with no test catching it.

Suggestion: Add a direct unit test for the normalization boundary:

from azure.cosmos._location_cache import _normalize_region_name

def test_normalize_preserves_distinct_regions():

    # Regions that differ only by trailing digit must stay distinct
    assert _normalize_region_name("East US") != _normalize_region_name("East US 2")
    assert _normalize_region_name("West US") != _normalize_region_name("West US 2")
    assert _normalize_region_name("West US 2") != _normalize_region_name("West US 3")

    # Verify exact canonical outputs
    assert _normalize_region_name("East US 2") == "eastus2"
    assert _normalize_region_name("East US") == "eastus"
    assert _normalize_region_name(None) == ""

This is cheap to add and protects the most important invariant in the PR.

⚠️ AI-generated review — may be incorrect. Agree? → resolve the conversation. Disagree? → reply with your reasoning.

# GetLocationalEndpoint is used during bootstrap fallback with the customer-supplied
# preferred region string. It must produce the canonical regional URL for any
# accepted normalization variant.
default_endpoint_url = "https://contoso.documents.azure.com:443/"
expected_endpoint = "https://contoso-eastus2.documents.azure.com:443/"

for region_input in ("East US 2", "east us 2", "eastus2", "east-us-2", "east_us_2", " EastUs2 "):
assert LocationCache.GetLocationalEndpoint(default_endpoint_url, region_input) == expected_endpoint

def test_unmatched_excluded_locations_warning_is_deduped(self, caplog):
connection_policy = documents.ConnectionPolicy()
connection_policy.ExcludedLocations = ["unknown-region"]
lc = refresh_location_cache([canonical_location1_name], True, connection_policy)
db_acc = create_database_account_with_canonical_regions(enable_multiple_writable_locations=True)
with caplog.at_level("WARNING", logger="azure.cosmos.LocationCache"):
lc.perform_on_database_account_read(db_acc)
request = RequestObject(ResourceType.Document, _OperationType.Read, None)
lc.resolve_service_endpoint(request)
lc.resolve_service_endpoint(request)
# Simulate a periodic refresh with unchanged topology and config.
lc.perform_on_database_account_read(db_acc)

unmatched_logs = [
record for record in caplog.records
if "Ignoring excluded_locations entries" in record.getMessage()
]
assert len(unmatched_logs) == 1

def test_unmatched_preferred_locations_warning_is_deduped(self, caplog):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Recommendation — Test Coverage: Warning re-emission on topology change is untested

The dedupe key at _emit_config_mismatch_warning_once intentionally includes tuple(sorted(available_by_normalized.keys())) so that warnings re-fire when the account topology changes (e.g., a new region appears). This is the right design — if the available regions change, the user should be re-notified that their config still doesn't match.

However, both test_unmatched_excluded_locations_warning_is_deduped and this test only exercise suppression (same db_acc repeated). Neither verifies the positive case: that a different topology does re-emit the warning (count == 2).

Suggestion: Add a test that changes the available regions between calls:

def test_topology_change_retriggers_mismatch_warning(self, caplog):
    lc = refresh_location_cache(["unknown-region"], True)
    db_acc1 = create_database_account_with_canonical_regions(True)
    db_acc2 = create_database_account_with_canonical_regions(True)

    # Mutate topology for the second refresh
    db_acc2._ReadableLocations.append(
        {"name": "New Region", "databaseAccountEndpoint": "https://newregion.documents.azure.com"}
    )
    with caplog.at_level("WARNING", logger="azure.cosmos.LocationCache"):
        lc.perform_on_database_account_read(db_acc1)
        lc.perform_on_database_account_read(db_acc2)
    warnings = [r for r in caplog.records if "Ignoring" in r.getMessage()]
    assert len(warnings) == 2  # One per distinct topology

This protects the deliberate dedupe-key design choice against accidental simplification.

⚠️ AI-generated review — may be incorrect. Agree? → resolve the conversation. Disagree? → reply with your reasoning.

with caplog.at_level("WARNING", logger="azure.cosmos.LocationCache"):
lc = refresh_location_cache(["unknown-region"], True)
db_acc = create_database_account_with_canonical_regions(enable_multiple_writable_locations=True)
lc.perform_on_database_account_read(db_acc)
# Simulate a periodic refresh with unchanged topology and config.
lc.perform_on_database_account_read(db_acc)

unmatched_logs = [
record for record in caplog.records
if "Ignoring preferred_locations entries" in record.getMessage()
]
assert len(unmatched_logs) == 1

if __name__ == "__main__":
unittest.main()
unittest.main()
Loading