Skip to content

KeyedRateLimitRef.key_fn receives an untyped payload dict rather than the validated model #60

Description

@rcbevans

KeyedRateLimitRef.key_fn is typed Callable[[dict[str, object]], str], and the dict it receives is the raw job-row payload, not the actor's validated payload model. In the consumer, the limiter is resolved with payload=job.payload — the JSON straight off the row — and the payload is validated against payload_type further down the same dispatch, after admission control has already run.

For a library this strict about typing everywhere else, that's a surprising seam, and it has consequences beyond ergonomics:

  • Pydantic defaults don't apply. A key field with a default that wasn't present in the serialized payload is simply missing from the dict, even though the attribute is populated on the model the handler receives. key_fn and the handler then disagree about the same field.
  • Aliases don't apply. With Field(alias="providerId"), the model exposes provider_id while the raw dict has providerId. key_fn has to know the wire name, so the routing key is coupled to serialization rather than to the model.
  • Failures surface in the wrong layer. If the field is absent or the wrong shape, the KeyError/TypeError is raised inside rate-limit resolution rather than as a payload-validation error, so an invalid payload looks like a limiter fault.
  • No type checking at the point it matters most. payload["provider"] is exactly the kind of expression a rename should break at check time and doesn't.

Today I keep the keyed field mandatory, never aliased, never defaulted, and always a plain scalar — an invariant that nothing enforces and that a future model refactor will quietly violate.

What I'd rather have is key_fn receiving the validated model:

KeyedRateLimitRef(
    base_name="api-per-tenant",
    key_fn=lambda p: p.tenant_id,   # typed, defaults and aliases applied
    capacity=10,
    refill_per_second=1.0,
)

The validated payload is constructed on the same dispatch anyway, so the data is available; the question is ordering, not availability. (Same argument applies to KeyedReservationRef.key_fn, which shares the signature.)

Open questions:

  • Is limits-before-validation deliberate — don't pay model_validate for a job you might not admit, or don't let a malformed payload consume a token? Both are defensible, and if that's the intent then moving validation earlier is the wrong fix.
  • If the ordering has to stay, would a typed opt-in work — the ref declaring the payload type so key_fn gets a validated model, falling back to the raw dict otherwise? That keeps the fast path and gives correctness to callers who want it.
  • Failing both, a docstring note that key_fn sees the pre-validation wire form, and that defaults and aliases therefore do not apply, would at least make the trap visible. Keyed rate-limit follow-ups: accept str subclasses from key_fn; surface keyed limits in the admin UI #32 tightened the return side of key_fn; this is the argument side.

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