Skip to content

fix(const): Method.get is case-insensitive, but RFC 9110 makes the method token case-sensitive #896

Closed

Description

@JarryShaw

Describe the bug

Method.get looks up HTTP method names case-insensitively. RFC 9110 makes the method token case-sensitive, so this conflates two distinct wire tokens.

pcapkit/const/http/method.py:232-242:

@staticmethod
def get(key: 'str', default: 'Optional[str]' = None) -> 'Method':
    ...
    name = key.upper()

Its docstring justifies this as "Looked up case-insensitively, since member names are canonicalised to upper case on registration" — a statement about this library's own member naming, not about the protocol.

What the specification says

RFC 9110 §9.1:

The method token is case-sensitive because it might be used as a gateway to object-based systems with case-sensitive method names. By convention, standardized methods are defined in all-uppercase US-ASCII letters.

Contrast RFC 9110 §5.1, which is a different thing and genuinely is case-insensitive:

Field names are case-insensitive and ought to be registered within the "Hypertext Transfer Protocol (HTTP) Field Name Registry"

And RFC 959 §4.1, which is why pcapkit.const.ftp.command.Command's equivalent override is correct and should stay:

The command codes are four or fewer alphabetic characters. Upper and lower case alphabetic characters are to be treated identically.

Reproduction

Source-read at origin/main, not run against a capture. Method.get('get') upper-cases to GET and resolves to the standardised member. Per RFC 9110 a request whose method token is get is a distinct, non-standardised method.

Expected behavior

Method.get should be case-sensitive — i.e. drop the override and use the base EnumRegistry.get, so a lowercase token resolves to an unregistered member carrying the caller's own casing rather than to GET.

Note the base already preserves caller casing for an unregistered value, and the existing override deliberately does too (its own comment says the value is "key exactly as given -- never name"), so the change is narrower than it looks: what moves is only whether a lowercase token matches a registered member.

System information

Source-only; no library version involved. Read at origin/main.

Additional context

Surfaced on #877 while settling that issue's case-folding ruling. The maintainer's ruling there is that enums keep the registrar's own spelling and that case-insensitivity applies only where the RFC recognises it — Command (FTP) qualifies under that test, Method (HTTP) does not. This issue is the concrete consequence; #877 remains the design question about where get lives.

Activity

  1. added
    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)
    fixPull requests that fix a defect (fix: subject prefix)
    constRegenerated IANA or vendor constant tables; members keep their numeric values
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 29, 2026
  2. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Correcting this issue's own Expected behavior section — I wrote it and it was wrong.

    I said:

    Method.get should be case-sensitive — i.e. drop the override and use the base EnumRegistry.get, so a lowercase token resolves to an unregistered member carrying the caller's own casing rather than to GET.

    The second half does not follow from the first. The base get raises for an unmatched str key; it never builds an unregistered member for a miss. Measured on FEATCode, a clean str registry with no override:

    FEATCode.get("zz-nope")  ->  RAISED KeyError
    

    So deleting the override would have turned Method.get('get') from "resolves to GET" into "raises" — not into "resolves to an unregistered member preserving 'get'", which is what the rest of that section asks for. Those are different outcomes and I conflated them.

    PR #907 took the correct shape instead: keep Method's override, remove only the .upper() fold from the match, and leave the canonicalized-name / preserved-value fallback intact. That gives case-sensitive matching and the unregistered-member-preserving-caller-casing behaviour the issue actually wants. The implementer found this by measuring rather than following my instruction, which is the right call.

    Two other things worth recording from the implementation:

    The override originates in the crawler, not the generated file. pcapkit/vendor/http/method.py carries its own LINE template rather than using the shared one in pcapkit/vendor/default.py, and that template's get() held the case-folding logic. So the fix went in the template and the const file was regenerated — which is the house rule, and worth noting because a reader looking only at pcapkit/const/http/method.py would conclude the override was hand-added there.

    Real HTTP/1 parsing is unaffected, so this is breaking only at the API contract. The single production call site is pcapkit/protocols/application/httpv1.py:434, and it is gated by _RE_METHOD = re.compile(rb"(?P<method>[A-Z][A-Z-]*)\Z") — uppercase-only by construction. So no capture changes behaviour; what changes is what a direct external caller gets for a non-canonical casing, which is exactly what three pre-existing tests had pinned as the old contract.

    The census in tests/const/test_const_registry_protocol.py does not move: Method still has a bespoke get(), just with different matching logic, and is still StrEnum-valued, so it stays in EXCLUDED_FROM_INTENUM_CENSUS for the same reason as before.

  3. removed
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 29, 2026
  4. added this to the 1.5 milestone on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)constRegenerated IANA or vendor constant tables; members keep their numeric valuesfixPull requests that fix a defect (fix: subject prefix)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions