Skip to content

Relative priority of routing rules can change from unrelated rules #3156

Description

@sdegueldre

Titular issue

Have two rules with a dynamic segment using different converters with the same weight in your application. When a request comes in that could match both rules, it matches the first because it was registered first, as documented:

Rules may end up with the same priority, by having static parts with the same length, and dynamic parts with the same weight, in the same positions. In this case, sorting is stable, so rules added earlier take priority.

Add a new rule in your application before the first (eg for code organization reasons), this rule should have a dynamic segment in the same place as both of the existing rules, and use the same converter as the second rule, but otherwise not match the incoming request's URL.

Incoming requests with the same URL as before now start matching the second rule, despite the fact that it has the same priority as the first rule and that the first rule was added earlier.

Reproduction:

def test_consistent_relative_priority():
    rule_1 = r.Rule("/<dummy:value>", endpoint="rule_1")
    rule_2 = r.Rule("/<string:value>", endpoint="rule_2")
    map = r.Map(
        [
            rule_1,
            rule_2,
        ], converters={'dummy': r.BaseConverter}
    )
    adapter = map.bind("example.org", "/")
    assert adapter.match("/foo") == ("rule_1", { "value": "foo"})

    map = r.Map(
        [
            r.Rule("/<string:value>/no_match", endpoint="no_match"),
            rule_1.empty(),
            rule_2.empty(),
        ], converters={'dummy': r.BaseConverter}
    )
    adapter = map.bind("example.org", "/")
    assert adapter.match("/foo") == ("rule_1", { "value": "foo"}) # Fails: rule_2 matches instead

Note that the dummy converter is just a stand-in for any custom converter with the default weight of 100. If both routes use the string converter in the first position, the issue does not occur because the rules are merged when being added to the map's matcher.

Expected behavior:
Non-matching rules should not affect the relative priority of following rules.

Environment:

  • Python version: 3.12.3
  • Werkzeug version: 3.0.1 -> main

Related issue

(let me know if I should create a separate issue for this, I'm putting this here because the root cause seems identical to me)

More specific rules will only have priority over more general ones if they use the same converter. When using different converters of the same weight, rules that have the common prefix that was registered first (regardless of the order the actual rules were registered, as demonstrated above) will have priority.

Reproduction:

def test_cross_converter_rule_specificity():
    map = r.Map(
        [
            r.Rule("/<string:value>/<path:path>", endpoint="less_specific"),
            r.Rule("/<string:value>/bar", endpoint="more_specific"),
        ]
    )
    adapter = map.bind("example.org", "/")
    assert adapter.match("/foo/bar") == ("more_specific", { "value": "foo"})

    map = r.Map(
        [
            r.Rule("/<string:value>/<path:path>", endpoint="less_specific"),
            r.Rule("/<dummy:value>/bar", endpoint="more_specific"),
        ], converters={'dummy': r.BaseConverter}
    )
    adapter = map.bind("example.org", "/")
    assert adapter.match("/foo/bar") == ("more_specific", { "value": "foo"}) # Fails: less_specific matches instead

Cause:

I'm not super familiar with the code base but from my understanding, this is caused by the way the StateMachineMatcher merges rules as they're being added, in particular:

# matcher.py L46-49
for test_part, new_state in state.dynamic:
    if test_part == part:
        state = new_state
        break

This code means that when adding a new rule, it takes the priority of whatever existing rule is already registered with which it shares a prefix (for dynamic parts, converters that yield the same RulePart).

Possible fixes

I'm not sure what the proper fix would be, as I'm not super familiar with the codebase and exact implications of each fix, but here are a few suggestions in increasing order of completeness and complexity:

  1. Document that converters themselves have a priority, and sort rule parts by weight and by order in which converters were registered on the routing map. This means that custom converters always have lower priority than default converters with the same weight. With this, in the first test, rule_2 will always match regardless of the rule order.
    Alternatively, if we want custom converters to win over default ones, it could be in reverse registration order: most recent converter wins, though this goes against the "first wins" principle applied elsewhere.

  2. When merging rules in the state machine, only merge the RulePart if it's the same as the last dynamic segment currently known, this fixes the first test case, but not the second, and is more of a band-aid fix, it gives us the priority-by-order property but doesn't fix the specificity problem, and this will result in less merging and likely more expensive matching in pathological cases where users are registering many rules with different converters of the same priority in the same segment in a staggered manner.

  3. The most general and complete fix that fixes both tests: merge dynamic parts of the same weight, even if they have different rules, but that means that State.dynamic would now need to be a map of the form {converter_weight: Array[RulePart]}. This has more profound implications w.r.t. how the state machine evaluates matches, as you would in principle need to take multiple state transitions in "parallel" for converters of the same weight, and end up with a sort of DFS/BFS hybrid through the state graph.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions