Evolving a handler's payload (versions, casting-handlers, transparent casting)

You shipped orders:place with a PlaceOrder payload. Now a field needs to change — count becomes quantity, say — but the old callers are still out there. Benzene versions the payload, not the URL: a message carries its version in a header, each version maps to its own handler, and a small toolkit lets one real implementation serve every version at once. This walkthrough takes you from "I shipped v1, now I need v2" through that toolkit, in the order you'd actually reach for it.

Prerequisites

1. How a version is carried and resolved

A message names its version in one header, benzene-version — the same name in every language, read inbound and echoed outbound. A caller's own version or x-version header (an API or SDK version, typically) is an ordinary header and selects nothing: reading it as a payload version would silently misroute the message to another version's handler. resolve_version reads the one header:

from benzene.core import VERSION_HEADER, resolve_version

VERSION_HEADER                                        # 'benzene-version'

resolve_version({"benzene-version": "v2"})            # 'v2'
resolve_version({"x-version": "v2"})                  # ''    (an ordinary header, not a version)
resolve_version({})                                   # ''    (no signal -> the topic's default handler)

If your producers already use a different name, rename it in one place — BenzeneMessageApplication(registry, version_header="schema-version") — and every transport reads it (and the response echoes it) under that name instead.

Over HTTP you don't touch headers at all — a {version} route segment drives selection. Register one route with the segment and let the message registry hold the versions:

from benzene.http import BenzeneHttpApp, HttpRouter
from benzene.core import BenzeneMessageApplication, Registry
from benzene.results import Result

async def get_v1(_req: dict) -> Result: return Result.ok({"served": "v1"})
async def get_v2(_req: dict) -> Result: return Result.ok({"served": "v2"})

registry = Registry()
registry.register("orders:get", get_v1, version="v1", default=True)
registry.register("orders:get", get_v2, version="v2")

router = HttpRouter()
router.register("GET", "/{version}/orders", "orders:get", get_v1)   # one route, {version} segment
app = BenzeneHttpApp(router, application=BenzeneMessageApplication(registry))

# GET /v1/orders -> {"served": "v1"} ;  GET /v2/orders -> {"served": "v2"}

The captured segment is supplied exactly as if the caller had sent the benzene-version header, so /v1/orders selects the "v1" handler and /v2/orders the "v2" one. (Prefer the literal prefix outside the capture — "/v{version}/orders" captures just "2", so register those handlers as version="2"; match your route to your version strings either way.) The segment drives selection only — it never leaks into the request the handler sees.

2. Registering versioned handlers

A (topic, version) pair maps to at most one handler. Register each version explicitly; the router builds the request into each handler's payload type, which it reads from the handler's first-parameter annotation (place_v1(request: PlaceOrderV1)) — no request_type= to repeat:

from dataclasses import dataclass
from benzene.core import Registry
from benzene.results import Result

@dataclass
class PlaceOrderV1:
    sku: str
    count: int = 1        # v1 called it `count`

@dataclass
class PlaceOrderV2:
    sku: str
    quantity: int = 1     # v2 renamed it `quantity`

async def place_v1(request: PlaceOrderV1) -> Result:
    return Result.created({"sku": request.sku, "quantity": request.count})

async def place_v2(request: PlaceOrderV2) -> Result:
    return Result.created({"sku": request.sku, "quantity": request.quantity})

registry = (
    Registry()
    .register("orders:place", place_v1, version="v1", default=True)  # request_type: PlaceOrderV1
    .register("orders:place", place_v2, version="v2")                # request_type: PlaceOrderV2
)

Selection is exact match (exact_version, the spec's rule): the requested version must be registered, or the message is a loud not-found naming the topic and the version. There is no "nearest" or "highest" fallback by default — serving another version's behaviour to a caller that named a version is exactly what versioning exists to prevent.

A message with no version goes to the topic's default handler: the unversioned handler if there is one, otherwise the one you declared with default=True (the @message decorator takes the same keyword). Declaring the oldest version you still accept the default is what keeps producers that predate versioning working once you turn it on. A topic whose handlers are all versioned and none declared default is valid, but every unversioned message to it is not-found, so building the application warns you with a NoDefaultHandlerWarning. Two default=True handlers on one topic is a DuplicateHandlerError at registration.

When you do want latest-wins fallback — an unknown or absent version served by the highest registered version — opt into the highest_version selector on the application:

from benzene.core import BenzeneMessageApplication, highest_version

app = BenzeneMessageApplication(registry, version_selector=highest_version)
# a request for v9 (or none) now falls back to v2; v10 would beat v2 by natural order, not string order

Reach for highest_version when you control every caller and want new deploys to pick up the newest contract automatically; keep the exact-match default when unknown versions are third-party callers you'd rather reject than mis-route. A selector is just a VersionSelector callable, so you can supply your own policy (semantic versioning, an allow-list) in its place.

3. Serving several versions with one real implementation

Registering a full handler per version means duplicating logic. Better: keep one real implementation on the newest payload and make every older version forward to it.

The casting-handler pattern (by hand)

Write a thin forwarding async def per retired version that upcasts the old payload to the current one and calls the shared implementation. No framework code — just an extra registration:

from benzene.core import Handler, Registry

async def place_v2(request: PlaceOrderV2) -> Result:          # the one real implementation
    return Result.created({"sku": request.sku, "quantity": request.quantity})

def make_place_v1(latest: Handler) -> Handler:
    async def place_v1(request: PlaceOrderV1) -> Result:
        return await latest(PlaceOrderV2(sku=request.sku, quantity=request.count))  # upcast v1 -> v2
    return place_v1

registry = (
    Registry()
    .register("orders:place", place_v2, version="v2")                    # inferred: PlaceOrderV2
    .register("orders:place", make_place_v1(place_v2), version="v1",     # inferred: PlaceOrderV1
              default=True)                                              # unversioned callers -> v1
)

Both place_v2 and the forwarding place_v1 annotate their request, so the router builds each into the right per-version type with no request_type=. A v1 caller (old count field) and a v2 caller (quantity) now both reach the single place_v2 implementation. This is enough when you have one or two old versions and no response to reshape.

Transparent casting (register the casts, not the forwarders)

When the versions pile up — or the response also changed — hand-writing a forwarder each time gets repetitive. Transparent casting replaces the forwarders with one-step casts between payload types, registered once on a SchemaCasters, and lets casting_handler build the forwarder for you. Register a cast in each direction you need — request upcasts (older → canonical) and response downcasts (canonical → older):

from dataclasses import dataclass
from benzene.core import SchemaCasters, casting_handler, Registry
from benzene.results import Result

@dataclass
class OrderPlacedV2:
    id: str
    quantity: int

@dataclass
class OrderPlacedV1:
    id: str            # v1's response never carried the quantity

async def place_v2(request: PlaceOrderV2) -> Result:
    return Result.created(OrderPlacedV2(id=f"ord-{request.sku}", quantity=request.quantity))

casters = (
    SchemaCasters()
    .cast_between(PlaceOrderV1, PlaceOrderV2, lambda v1: PlaceOrderV2(sku=v1.sku, quantity=v1.count))
    .cast_between(OrderPlacedV2, OrderPlacedV1, lambda v2: OrderPlacedV1(id=v2.id))   # response down
)

registry = (
    Registry()
    .register("orders:place", place_v2, version="v2",                      # the real implementation
              response_type=OrderPlacedV2)                                 # request_type inferred
    .register("orders:place",                                              # v1, served transparently
              casting_handler(place_v2, casters, to=PlaceOrderV2, response_to=OrderPlacedV1),
              version="v1", request_type=PlaceOrderV1, response_type=OrderPlacedV1,
              default=True)
)

Now a v1 caller sends {"sku": "A", "count": 3}; casting_handler upcasts it to PlaceOrderV2, the handler runs on the canonical type and returns OrderPlacedV2, and the wrapper downcasts that to OrderPlacedV1 — so quantity is dropped from the reply. The v2 caller is untouched. A few points worth pinning down:

Why one registration per served version? The spec's transparent-casting mechanism serves any incoming version off a single registration, upcasting whatever arrives. That presumes non-exact routing — accept and cast an unrecognised version rather than reject it — which contradicts this port's fail-loud default (an unknown version is a not-found). So the port keeps §4's substance (a shared SchemaCasters, casts that compose) but each served version stays one deliberate, discoverable registration; casting_handler just collapses it to a one-liner. This is spelled out under "Transparent casting" in the benzene.core reference.

Test it

Drive the whole thing through BenzeneMessageApplication.handle with a version header and assert on the mapped envelope — the v1 reply should have shed the field the v2 response added:

import asyncio, json
from benzene.core import BenzeneMessageApplication

app = BenzeneMessageApplication(registry)

v1 = asyncio.run(app.handle({
    "topic": "orders:place", "headers": {"version": "v1"}, "body": '{"sku": "A", "count": 3}',
}))
assert v1["statusCode"] == "created"
assert json.loads(v1["body"]) == {"id": "ord-A"}          # downcast dropped `quantity`

v2 = asyncio.run(app.handle({
    "topic": "orders:place", "headers": {"benzene-version": "v2"}, "body": '{"sku": "A", "quantity": 3}',
}))
assert json.loads(v2["body"]) == {"id": "ord-A", "quantity": 3}   # canonical response, untouched

See also