Middleware
Two hooks, and the difference between them
MethodGroup is the extension point for cross-cutting concerns. There are two hooks, and picking the wrong one is the single most consequential mistake you can make with this library:
| Hook | Runs on | Use it for |
|---|---|---|
around_call() |
every group on the resolved path, outermost first | guards, logging, caching, rate limiting — anything that must cover a namespace |
execute_method() |
only the group the method is registered on | changing how that group's own methods are invoked |
A method registered as api.v1.search.items is owned by the innermost group. execute_method() runs there and nowhere else. around_call() runs on api, then v1, then search, then the call reaches the method.
If you are upgrading from 0.3.x
In 0.3.x, execute_method() was the only hook, and dispatch called it on the owning group alone. A guard mounted above a subgroup never ran — the request reached the method with no error and no log line. If you have a MethodGroup subclass that guards, logs, or limits calls into nested subgroups, move that logic to around_call(). Mounting a group that overrides execute_method() but owns only subgroups is now rejected at registration, so the broken arrangement fails at startup instead of silently letting calls through.
The examples below are reference implementations showing common patterns. They are intentionally simplified: no persistence, no distributed coordination, no production-grade edge cases. Treat them as a starting point and adapt them to your stack.
Context for Middleware
All examples on this page share a common typed context. In a real app your context will carry whatever your transport layer can provide — user ID from a session, IP from the request, tenant ID from a subdomain, and so on.
from dataclasses import dataclass
@dataclass
class AppContext:
user_id: int | None
ip_address: str
The around_call signature
def around_call(self, call: CallInfo, context: AppContext, call_next):
# before
result = call_next(context) # continue down the chain
# after
return result
callcarriescall.path(the full dotted path as requested),call.method(theMethodinstance) andcall.params(already validated).contextis what the caller passed tohandle(), possibly replaced by a group further out.call_next(context)continues the chain. The innermost one invokes the owning group'sexecute_method(). Not calling it vetoes the call. Pass a different context to enrich what everything below sees.
Annotate context. That annotation is the group's declared context_type, and
the library checks it at registration: a method demanding a context the group
does not promise is refused there rather than at the first request. Leaving it
off is legal and simply means the group declares nothing.
Async dispatch uses around_call_async(), whose default awaits the rest of the chain:
async def around_call_async(self, call: CallInfo, context: AppContext, call_next):
result = await call_next(context)
return result
Override both, or neither
A synchronous around_call() cannot await an async method — its call_next would hand it an unfinished coroutine, and any post-processing would run against that instead of the result. So a group that overrides only around_call() refuses to accept an async method below it at registration time. If your namespace contains any async def execute, override both hooks. The same rule applies to the execute_method() / execute_method_async() pair.
Logging Middleware
Logging is the most universally useful middleware. Once you have it, debugging production issues goes from "guessing" to "reading logs". The example below records the method path, authenticated user, and wall-clock duration. On failure it logs the error and re-raises, so the original exception propagates unchanged.
Note that it logs call.path, not the method's class name: the path is what identifies a call, and one class can be registered under several names.
What you might want to add in a real implementation: structured logging (JSON), correlation IDs, log levels per method, sampling for high-throughput endpoints.
import time
import logging
from jsonrpc import CallInfo, MethodGroup
logger = logging.getLogger('rpc')
class LoggingGroup(MethodGroup):
def around_call(self, call: CallInfo, context: AppContext, call_next):
user = context.user_id or "anonymous"
start = time.perf_counter()
logger.info(f"→ {call.path} [user={user}]")
try:
result = call_next(context)
duration = time.perf_counter() - start
logger.info(f"← {call.path} completed in {duration:.3f}s")
return result
except Exception as e:
duration = time.perf_counter() - start
logger.error(f"FAILED: {call.path} failed in {duration:.3f}s: {e}")
raise
async def around_call_async(self, call: CallInfo, context: AppContext, call_next):
user = context.user_id or "anonymous"
start = time.perf_counter()
logger.info(f"→ {call.path} [user={user}]")
try:
result = await call_next(context)
duration = time.perf_counter() - start
logger.info(f"← {call.path} completed in {duration:.3f}s")
return result
except Exception as e:
duration = time.perf_counter() - start
logger.error(f"FAILED: {call.path} failed in {duration:.3f}s: {e}")
raise
Rate Limiting Middleware
Rate limiting protects the server from being overwhelmed by a single caller — whether that is a misbehaving client or an abuse attempt. The example uses an in-process sliding window counter keyed by user_id (or by IP for anonymous requests).
This is enough for a single-process deployment. For a multi-process or distributed setup you would replace the in-memory dict with Redis or another shared store. You might also want to differentiate limits per method, per role, or per plan tier.
from collections import defaultdict
import time
from jsonrpc import CallInfo, MethodGroup
from jsonrpc.errors import ServerError
class RateLimitGroup(MethodGroup):
def __init__(self, max_calls: int = 60, window: int = 60):
super().__init__()
self.max_calls = max_calls
self.window = window
self.calls: dict[str, list[float]] = defaultdict(list)
def _check(self, context: AppContext) -> None:
identifier = str(context.user_id) if context.user_id else context.ip_address
now = time.time()
self.calls[identifier] = [
t for t in self.calls[identifier]
if now - t < self.window
]
if len(self.calls[identifier]) >= self.max_calls:
remaining = int(self.window - (now - self.calls[identifier][0]))
raise ServerError(
f"Rate limit exceeded. Try again in {remaining}s",
code=-32029
)
self.calls[identifier].append(now)
def around_call(self, call: CallInfo, context: AppContext, call_next):
self._check(context)
return call_next(context)
async def around_call_async(self, call: CallInfo, context: AppContext, call_next):
self._check(context)
return await call_next(context)
Authentication Middleware
RequireAuthGroup is a guard: it blocks any call where context.user_id is None and lets everything else through. It does not validate tokens — that is the transport layer's job (see below).
This separation is intentional. The transport layer (Flask/FastAPI route handler) is the right place to validate credentials: it has access to HTTP headers, cookies, and framework-specific helpers like JWT libraries or session stores. RequireAuthGroup enforces the requirement; it cannot verify anything the transport did not.
The guard is only as good as the context
RequireAuthGroup trusts context.user_id. That value is trustworthy only if the transport handler verified a credential before building the context. A handler that copies a request header into user_id grants whatever the caller asserts, and this guard will happily wave it through. See the transport example below for what verification looks like.
from jsonrpc import CallInfo, MethodGroup
from jsonrpc.errors import JSONRPCError
class Unauthenticated(JSONRPCError):
"""No verified caller. -32000..-32099 is reserved for exactly this."""
code = -32010
message = 'Authentication required'
class RequireAuthGroup(MethodGroup):
def _check(self, context: AppContext) -> None:
if context.user_id is None:
raise Unauthenticated()
def around_call(self, call: CallInfo, context: AppContext, call_next):
self._check(context)
return call_next(context)
async def around_call_async(self, call: CallInfo, context: AppContext, call_next):
self._check(context)
return await call_next(context)
Refuse with a JSONRPCError, or the refusal disappears
Only a JSONRPCError subclass keeps its code and its message on the way out.
Everything else — PermissionError, RuntimeError, a framework's own
Forbidden — is caught as an unhandled exception and answered with a bare
{"code": -32603, "message": "Internal error"}, with a full traceback logged
at ERROR level. Per unauthorized attempt. The caller cannot tell a refusal
from a server fault, and neither can you, reading the log.
-32602 Invalid params is a poor fit for the same reason in reverse: the
caller cannot tell "you are not allowed" from "you misspelled a field". Pick
a code in the implementation-defined range and mean it.
Transport layer — where token validation actually happens:
from flask import Flask, request
from jwt import decode, InvalidTokenError
app = Flask(__name__)
@app.route('/rpc', methods=['POST'])
def handle_rpc():
if not request.mimetype.startswith('application/json'):
return {'error': 'Content-Type must be application/json'}, 415
user_id = None
token = request.headers.get('Authorization', '').removeprefix('Bearer ')
if token:
try:
payload = decode(token, SECRET_KEY, algorithms=['HS256'])
user_id = payload['user_id']
except InvalidTokenError:
pass
ctx = AppContext(user_id=user_id, ip_address=request.remote_addr)
response = rpc.handle(request.data, context=ctx)
if response is None: # notification: nothing to send back
return '', 204
return response, 200, {'Content-Type': 'application/json'}
Why not validate inside the middleware?
Middleware runs inside the RPC layer, which knows nothing about HTTP, tokens, or sessions. Putting validation there would couple the protocol layer to your auth library. Keeping it in the transport handler makes each layer responsible for exactly one thing.
Caching Middleware
Caching is useful for methods that are expensive to compute but whose result changes infrequently — think search suggestions, catalog data, or config lookups. The example stores results in an in-process dict with a TTL.
Two details in the key deserve attention:
- It keys on
call.path, not on the method's class name. TheMethodGroupdocstring teaches registering two instances of one class under different names (ReportMethod(scope='mine')andReportMethod(scope='everyone')). Keyed by class name, those two produce identical key material and share one cache slot — a low-privilege sibling can then prime the slot a privileged sibling reads, in both directions. - It does not key on
context. That is correct only for data that is the same for every caller. If results differ per user, add the user to the key:f"{call.path}:{context.user_id}:{...}".
import hashlib
import json
import time
from jsonrpc import CallInfo, MethodGroup
class CachingGroup(MethodGroup):
def __init__(self, ttl_seconds: int = 60):
super().__init__()
self.ttl = ttl_seconds
self._cache: dict[str, object] = {}
self._timestamps: dict[str, float] = {}
def _cache_key(self, call: CallInfo) -> str:
raw = f"{call.path}:{json.dumps(call.params, sort_keys=True, default=str)}"
return hashlib.md5(raw.encode()).hexdigest()
def around_call(self, call: CallInfo, context: AppContext, call_next):
key = self._cache_key(call)
now = time.time()
if key in self._cache and now - self._timestamps[key] < self.ttl:
return self._cache[key]
result = call_next(context)
self._cache[key] = result
self._timestamps[key] = now
return result
A cache must sit above every guard, never below one
On a hit, around_call returns without calling call_next, so everything below it is skipped — inner groups, the owning group's execute_method(), the library's own runtime context check, and any authorization the method performs inside execute(). A user whose access was revoked is still served a primed key; a primed key is also unmetered if the rate limiter sits inside the cache.
Mount the cache innermost among your middleware, so no guard sits below it — or make the key carry every input the guards depend on, which is the same thing said less safely. Per-user keys separate callers, but they do not re-run a check.
Full Example
The four classes above compose cleanly. Each group wraps the next, forming a chain where the request passes through every layer on the way in and every layer on the way back out.
from dataclasses import dataclass
from jsonrpc import JSONRPC, MethodGroup, Method
@dataclass
class SearchParams:
query: str
limit: int = 10
@dataclass
class SearchResult:
id: int
title: str
user_id: int
class Search(Method):
def execute(self, params: SearchParams, context: AppContext) -> list[SearchResult]:
"""Search items for authenticated user."""
return [SearchResult(id=1, title=f"Result for: {params.query}", user_id=context.user_id)]
# Assemble middleware stack
rpc = JSONRPC(version='2.0', context_type=AppContext)
core = MethodGroup()
core.register('items', Search())
# Cache innermost: no guard may sit below it, or a cache hit would skip that guard.
cached = CachingGroup(ttl_seconds=60)
cached.register('search', core)
authed = RequireAuthGroup()
authed.register('protected', cached)
rate_limited = RateLimitGroup(max_calls=30, window=60)
rate_limited.register('api', authed)
logged = LoggingGroup()
logged.register('v1', rate_limited)
rpc.register('public', logged)
# Method: public.v1.api.protected.search.items
# Chain: Logging → Rate Limiting → Auth → Caching → Search
JSON-RPC Request:
{
"jsonrpc": "2.0",
"method": "public.v1.api.protected.search.items",
"params": {"query": "python"},
"id": 1
}
Transport call:
ctx = AppContext(user_id=42, ip_address="10.0.0.1")
response = rpc.handle(request_json, context=ctx)
An anonymous caller — AppContext(user_id=None, ...) — gets -32010 Authentication required, and Search.execute() never runs, no matter how deeply it is nested below the guard.
Key Points
- Use
around_call()for anything that must cover a namespace;execute_method()only ever runs on the group that owns the method - Override
around_call_async()too if any method below the group isasync def— registration rejects the half-overridden pair - Define a typed
AppContextdataclass — use it throughout all middleware - Access
context.user_iddirectly — nohasattr, noisinstance - Transport layer (Flask/FastAPI) constructs the context and is the only place that can verify a credential
- A guard raises; it does not mutate the context. To enrich the context, pass a new one to
call_next() - Order matters: the outermost group runs first on the way in and last on the way out
- Put caches innermost, below every guard
These are starting points, not production libraries
The examples on this page use in-process storage and have no persistence, no distributed coordination, and no thread safety guarantees beyond what CPython's GIL provides. Adapt them to your infrastructure — swap the dict for Redis, add locks where needed, hook into your logging framework.
Groups and methods are shared across requests
A Method or MethodGroup instance is a singleton: one object serves every request, on every thread. Keep per-request data in context, never on self. The self.calls dict in RateLimitGroup above is deliberate shared state, not per-request state — and it is exactly the kind of thing that needs a lock in a threaded server.
What's Next?
→ API Reference - Complete technical reference