Skip to content

Enhance endpoint selection by resolving ambiguities through constraint specificity analysis #62756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

sami-daniel
Copy link

@sami-daniel sami-daniel commented Jul 16, 2025

Enhance endpoint selection by resolving ambiguities through constraint specificity analysis

Improved endpoint selection via constraint specificity analysis

Description

Previously, if you had two endpoints as follows:

GET test/{name:required}

and

GET test/{id:guid:required}

The DefaultEndpointSelector would not be able to determine which endpoint is the correct
one among the two, as neither violates the Route Constraint policy and therefore are
considered valid candidates. From a human perspective, the endpoint that has the constraint of the GUID is more specific than simply REQUIRED, but the engine did not make that distinction. Now, the DefaultEndpointSelector can eliminate ambiguities based on their constraint level of priority.

Lets say the same routes described above. The engine will get two candidates available for it, but it can remove the first one cause its less specific than second one. But it is interesting to note that if the system cannot determine the priority, it will still report the ambiguity. Let's assume that the second request was equal to the first, it would trigger AmbiguousMatchException.

The currently specifity order is defined as (from higher to lower one):

1 - Strong typed route constraint (e.g int, guid, long etc.)
2 - Ranged route contraint (e.g min, max, range)
3 - Length route constraint (e.g length, minlength, maxlength)
4 - String patterns (e.g regex, alphanumerical)
5 - File and non file
6 - Unknown route constraint
7 - Required route constraint

As mentioned earlier, if after processing the ambiguities he still hasn't been able to
determine a specificity, he will report the ambiguity, since there is nothing to be
done in this case : ).

Fixes #62278

…t specificity analysis

Reduce ambiguity between constraints by defining a level of specificity between the constraints.

Previously, if you had two endpoints as follows:

```
GET test/{id:required}
```

and

```
GET test/{id:guid:required}
```

The DefaultEndpointSelector would not be able to determine which endpoint is the correct
one among the two, as neither violates the Route Constraint policy and therefore are
considered valid candidates. From a human perspective, the endpoint that has the constraint
of the GUID is more specific than simply REQUIRED, but the engine did not make that
distinction. Now, the DefaultEndpointSelector can eliminate ambiguities based on their
constraint level of priority.

Lets say the same routes described above. The engine will get two candidates available for it,
but it can remove the first one cause its less specific than second one. But it is interesting
to note that if the system cannot determine the priority, it will still report the ambiguity.
Let's assume that the second request was equal to the first, it would trigger AmbiguousMatchException.

The currently specifity order is defined as (from higher to lower one):

1 - Strong typed route constraint (e.g int, guid, long etc.)
2 - Ranged route contraint (e.g min, max, range)
3 - Length route constraint (e.g length, minlength, maxlength)
4 - String patterns (e.g regex, alphanumerical)
5 - File and non file
6 - Unknown route constraint
7 - Required route constraint

As mentioned earlier, if after processing the ambiguities he still hasn't been able to
determine a specificity, he will report the ambiguity, since there is nothing to be
done in this case : ).
@github-actions github-actions bot added the needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically label Jul 16, 2025
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Jul 16, 2025
@martincostello martincostello added area-routing and removed needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically labels Jul 16, 2025
@sami-daniel
Copy link
Author

I am not sure if those who failed are related with the changes.

@captainsafia
Copy link
Member

@sami-daniel These failures appear to be flakes in our build and not related to your change. I can kick the build here.

@davidfowl
Copy link
Member

davidfowl commented Jul 18, 2025

This is a hot path. Are we adding allocations?

Why do we need this logic in the default selector instead of one you can opt into?

Copy link
Member

@captainsafia captainsafia left a comment

Choose a reason for hiding this comment

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

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

@@ -60,6 +60,8 @@ private static void ProcessFinalCandidates(
Endpoint? endpoint = null;
RouteValueDictionary? values = null;
int? foundScore = null;
var candidatesWithSameScore = new List<CandidateState>();
Copy link
Member

Choose a reason for hiding this comment

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

Can we initialize this the first time two candidates with the same score are detected?

// Assert 2
Assert.Same(endpoint2, httpContext.GetEndpoint());
}

Copy link
Member

Choose a reason for hiding this comment

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

Can we add a test for the case where the match is still ambiguous if there are specificity constraints that match?

{
// Strong typed constraints that are very restrictive and has
// the highest specificity
"guid" => 100,
Copy link
Member

Choose a reason for hiding this comment

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

Using .Equals with a StringComparison to ignore casing might be more consistent than calling ToLowerInvariant for every constraint.

@davidfowl
Copy link
Member

davidfowl commented Jul 18, 2025

I’d follow up here by looking for benchmarks, for scenarios where this new code should not impact simple scenarios. At a glance, it does not look pay for play.

@javiercn
Copy link
Member

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Few things.

I don't believe that the endpoint selector is the right place to make this change. Factoring it on the route precedence would make more sense.

That said, I think this change is problematic for several reasons:

  • It impacts how routing behaves, which is not an ASP.NET Core only concern (precedence needs to be accounted for in the Blazor router).
  • Constraints allow general code execution to participate in the matching process, ergo it's really hard if not impossible to correctly set a precedence, especially when custom user code is involved.
  • Finally, I don't see general applicability for having to distinguish between two routes based uniquely on the constraints. It feels that the change adds significant overhead and complexity just to cover a "niche" scenario.
  • In many cases, you can disambiguate in different ways by negating one of the constraints in one route. That to me feels like a more solid approach that keeps the routing behavior deterministic and doesn't involve having to "guess" the priorities that constraints impose.
  • If we would want to take it further, we could introduce a pseudo-constraint to negate any existing constraint applied to a route like :not(:int) to address these scenarios.

I think before we even consider a change in this area, we need to discuss whether this is even a change that we want to make at all, and that should be something we discuss on an issue rather than on a PR.

@sami-daniel
Copy link
Author

This is a hot path. Are we adding allocations?

Why do we need this logic in the default selector instead of one you can opt into?

Yes, we are adding allocations, and worse. We are copying the candidateState unnecessarily to the list when we could use it by reference. I will change that.

@sami-daniel
Copy link
Author

  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Thanks for taking the time to work on this change, @sami-daniel!

I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Placing these changes in the DefaultEndpointSelector was a mistake, as it's not actually the source of the problem. The issue lies with its caller — the DFAMatcher. The DefaultEndpointSelector is just the default implementation used by the engine when no custom endpoint selector is defined. In contrast, the DFAMatcher cannot be replaced or customized with its own selector, so it makes more sense for the changes to be applied there. The real problem is that the DFAMatcher does not consider the weight of constraints, as described on the pr — it only checks whether they are valid.

I confess that I did not notice or pay attention to the use of RoutePrecedence in the code. From what I saw, it is used in the DFAMatcher builder, and in some other places that I am not sure are related. I am not sure if it makes sense to make changes to RoutePrecedence, since it is not the direct cause of the problem. I would need to study a bit more to see where it applies...

@sami-daniel
Copy link
Author

sami-daniel commented Jul 18, 2025

Thanks for taking the time to work on this change, @sami-daniel!
I left some notes on the current implementation but have two big picture thoughts:

  • Given the nature of the change, we'll likely have to document this as a breaking behavioral change in routing given it changes the contract the router makes.
  • I wonder if it might be better to support this feature by changing the logic that calculates precedence for route templates to account for constraints (ref).

Cc: @javiercn @JamesNK for additional routing expertise

Few things.

I don't believe that the endpoint selector is the right place to make this change. Factoring it on the route precedence would make more sense.

That said, I think this change is problematic for several reasons:

  • It impacts how routing behaves, which is not an ASP.NET Core only concern (precedence needs to be accounted for in the Blazor router).
  • Constraints allow general code execution to participate in the matching process, ergo it's really hard if not impossible to correctly set a precedence, especially when custom user code is involved.
  • Finally, I don't see general applicability for having to distinguish between two routes based uniquely on the constraints. It feels that the change adds significant overhead and complexity just to cover a "niche" scenario.
  • In many cases, you can disambiguate in different ways by negating one of the constraints in one route. That to me feels like a more solid approach that keeps the routing behavior deterministic and doesn't involve having to "guess" the priorities that constraints impose.
  • If we would want to take it further, we could introduce a pseudo-constraint to negate any existing constraint applied to a route like :not(:int) to address these scenarios.

I think before we even consider a change in this area, we need to discuss whether this is even a change that we want to make at all, and that should be something we discuss on an issue rather than on a PR.

Yes, those are great points to consider. It may really not be worth pursuing. The DFA/DFAMatcher is already a solid algorithm that performs its task well. As you mentioned, introducing additional overhead to this hot path might not be a good idea — even if it’s just to eliminate ambiguity and align more closely with how framework users think.

From what I can tell, making this change without affecting a critical path in the router is nearly impossible, since its a hot path. It seems like a very costly tradeoff. On top of that, as you pointed out, there are other cases that would also need to be handled. This could potentially require an entirely new pattern matching layer to cover all scenarios, adding even more overhead.

Maybe your approach to negating a constraint is much more interesting and much cheaper. The control of this would be in the user's hands, not altering the current behavior of the Router and not adding any overhead. Such a constraint would pave the way for a new range of other possible routes to be created with similar templates. I really would like to do this 😁😁.

@sami-daniel
Copy link
Author

As @javiercn said, perhaps it would be better, instead of adding overhead to DFAMatcher or the endpoint selector itself with a very niche change with little gain and a lot of tradeoff, to abstract this and leave control to the user. A new constraint that negates (:not(:int)), :not(:guid))) other constraints could be interesting to increase the number of possible templates and provide more flexibility with minimal cost. From another point of view, perhaps an exhaustive regex could handle a :not(:guid)), but I think the intention is to make things easier and not more complicated. I think it's worth pursuing this path. It seems like a complex challenge, but interesting to consider. I would like to work on it. If you have any ideas 😎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-routing community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

AmbiguousMatchException thrown when using required route constraint
5 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy