Request/Reply

Request/reply is where a requester sends a message and waits for a response, with the response sent back to the requester. In AsyncFast the reply of the interaction is the message the channel handler sends, so the pattern is declared by passing Reply to the channel.

Basic Usage

Marking a channel with Reply adds the AsyncAPI Operation Reply object to the receive operation, where the reply channel is the channel of the message the handler sends:

from collections.abc import AsyncGenerator
from dataclasses import dataclass

from asyncfast import AsyncFast
from asyncfast import Message
from asyncfast import Reply

app = AsyncFast()


@dataclass
class Pong(Message, address="pong"):
    payload: str


@app.channel("ping", reply=Reply())
async def ping(payload: str) -> AsyncGenerator[Pong, None]:
    yield Pong(payload="pong")
{
  "asyncapi": "3.0.0",
  "info": {
    "title": "AsyncFast",
    "version": "0.1.0"
  },
  "channels": {
    "Ping": {
      "address": "ping",
      "messages": {
        "PingMessage": {
          "$ref": "#/components/messages/PingMessage"
        }
      }
    },
    "Pong": {
      "address": "pong",
      "messages": {
        "Pong": {
          "$ref": "#/components/messages/Pong"
        }
      }
    }
  },
  "operations": {
    "receivePing": {
      "action": "receive",
      "channel": {
        "$ref": "#/channels/Ping"
      },
      "reply": {
        "channel": {
          "$ref": "#/channels/Pong"
        },
        "messages": [
          {
            "$ref": "#/channels/Pong/messages/Pong"
          }
        ]
      }
    },
    "sendPong": {
      "action": "send",
      "channel": {
        "$ref": "#/channels/Pong"
      }
    }
  },
  "components": {
    "messages": {
      "PingMessage": {
        "payload": {
          "type": "string"
        }
      },
      "Pong": {
        "payload": {
          "type": "string"
        }
      }
    }
  }
}

The handler must send exactly one message type, otherwise an InvalidChannelDefinitionError is raised when the channel is registered, that is, when the @app.channel(...) decorator is applied.

Reply works with either sending style, so the MessageSender can be used instead of yielding:

@app.channel("ping", reply=Reply())
async def ping(message_sender: MessageSender[Pong]) -> None:
    await message_sender.send(Pong(payload="pong"))

Dynamic Reply Address

When the address of the reply isn’t known at design time, it is determined at runtime from the request itself. This is typically done by the requester including a replyTo header. Marking that header with ReplyTo tells AsyncFast it carries the reply address, so the AsyncAPI reply address is derived from it and the header only needs to be written once:

from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Annotated

from asyncfast import AsyncFast
from asyncfast import Message
from asyncfast import Reply
from asyncfast import ReplyTo

app = AsyncFast()


@dataclass
class Pong(Message, address="{reply_to}"):
    reply_to: str
    payload: str


@app.channel("ping", reply=Reply())
async def ping(
    payload: str,
    reply_to: Annotated[str, ReplyTo(alias="replyTo")],
) -> AsyncGenerator[Pong, None]:
    yield Pong(reply_to=reply_to, payload="pong")
{
  "asyncapi": "3.0.0",
  "info": {
    "title": "AsyncFast",
    "version": "0.1.0"
  },
  "channels": {
    "Ping": {
      "address": "ping",
      "messages": {
        "PingMessage": {
          "$ref": "#/components/messages/PingMessage"
        }
      }
    },
    "Pong": {
      "address": null,
      "messages": {
        "Pong": {
          "$ref": "#/components/messages/Pong"
        }
      }
    }
  },
  "operations": {
    "receivePing": {
      "action": "receive",
      "channel": {
        "$ref": "#/channels/Ping"
      },
      "reply": {
        "address": {
          "location": "$message.header#/replyTo"
        },
        "channel": {
          "$ref": "#/channels/Pong"
        },
        "messages": [
          {
            "$ref": "#/channels/Pong/messages/Pong"
          }
        ]
      }
    },
    "sendPong": {
      "action": "send",
      "channel": {
        "$ref": "#/channels/Pong"
      }
    }
  },
  "components": {
    "messages": {
      "PingMessage": {
        "headers": {
          "$ref": "#/components/schemas/PingMessageHeaders"
        },
        "payload": {
          "type": "string"
        }
      },
      "Pong": {
        "payload": {
          "type": "string"
        }
      }
    },
    "schemas": {
      "PingMessageHeaders": {
        "properties": {
          "replyTo": {
            "title": "Replyto",
            "type": "string"
          }
        },
        "required": [
          "replyTo"
        ],
        "title": "PingMessageHeaders",
        "type": "object"
      }
    }
  }
}

ReplyTo behaves like Header, so the value is injected into the handler, and it can equally be used in a dependency of the channel. A channel can have at most one ReplyTo header.

The derived location is an AsyncAPI runtime expression, $message.header#/replyTo in the example above. To describe the address, or to point at a location that isn’t a header marked with ReplyTo, pass a ReplyAddress to Reply. The location must be either $message.header or $message.payload followed by a JSON Pointer. As a shortcut, a location can also be passed as a string:

@app.channel("ping", reply=Reply(address="$message.payload#/replyTo"))
async def ping(payload: PingPayload) -> AsyncGenerator[Pong, None]:
    yield Pong(payload="pong")

The reply address is documentation only, so the handler is still responsible for sending to the address the requester supplied. Binding the ReplyTo header to a parameter of the reply channel address, as in the example above, is the most direct way of doing this.

Note

AsyncAPI 3.0 requires that, when a reply address is specified, the address of the channel referenced by the reply is null. When Reply is given an address, the reply channel is therefore rendered with a null address, with the reply address runtime expression carrying the routing information. The reply message should not be reused as the send message of a standalone channel, since its channel address is rendered as null.

Same Reply Address

The request and the reply can also share an address, which is a reply channel using the same address as the request channel:

@dataclass
class Pong(Message, address="ping"):
    payload: str


@app.channel("ping", reply=Reply())
async def ping(payload: str) -> AsyncGenerator[Pong, None]:
    yield Pong(payload="pong")

Note

AsyncFast generates a channel per message sent, so the reply is a new channel which happens to reuse the request address, rather than the request channel itself.