How to Create and Chain Method Interceptors¶
Problem¶
You want to add cross-cutting behaviour -- logging, security checks, caching, retry logic -- to specific methods on your components without modifying their business logic.
Solution¶
1. Create an interceptor component¶
An interceptor is a @component that implements the MethodInterceptor protocol: a single invoke(ctx, call_next) method.
import time
from pico_ioc import component, MethodCtx, MethodInterceptor
@component
class TimingInterceptor:
"""Measures and logs method execution time."""
def invoke(self, ctx: MethodCtx, call_next) -> object:
start = time.perf_counter()
result = call_next(ctx)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{ctx.cls.__name__}.{ctx.name} took {elapsed_ms:.1f}ms")
return result
2. Apply the interceptor to methods¶
Use @intercepted_by(InterceptorClass) on the methods you want to intercept:
from pico_ioc import component, intercepted_by
@component
class OrderService:
@intercepted_by(TimingInterceptor)
def place_order(self, order_id: str) -> str:
# business logic
return f"Order {order_id} placed"
3. Chain multiple interceptors¶
Pass multiple interceptor classes. They execute in the order listed, wrapping around each other:
@component
class SecurityInterceptor:
def invoke(self, ctx: MethodCtx, call_next):
if not ctx.kwargs.get("user"):
raise PermissionError("Unauthenticated")
return call_next(ctx)
@component
class OrderService:
@intercepted_by(SecurityInterceptor, TimingInterceptor)
def place_order(self, order_id: str, user: str = "") -> str:
return f"Order {order_id} placed by {user}"
Execution order:
SecurityInterceptor.invoke()
-> TimingInterceptor.invoke()
-> OrderService.place_order() (the real method)
4. Async interceptors¶
Interceptors work with both sync and async methods. For async methods, the interceptor chain is awaited automatically:
@component
class RetryInterceptor:
async def invoke(self, ctx: MethodCtx, call_next):
for attempt in range(3):
try:
result = call_next(ctx)
if hasattr(result, "__await__"):
result = await result
return result
except Exception:
if attempt == 2:
raise
Explanation¶
When a component has @intercepted_by on any method, the container wraps the entire component in a UnifiedComponentProxy. On each method call:
- The proxy detects the interceptor classes from
_pico_interceptors_. - It resolves each interceptor class from the container (they are components themselves).
dispatch_method()builds a chain: eachcall_nextinvocation calls the next interceptor, and the finalcall_nextcalls the real method.- Wrapped methods are cached per scope signature to avoid repeated setup.
flowchart LR
Caller -->|"place_order()"| Proxy["UnifiedComponentProxy"]
Proxy --> IC1["SecurityInterceptor.invoke()"]
IC1 -->|"call_next(ctx)"| IC2["TimingInterceptor.invoke()"]
IC2 -->|"call_next(ctx)"| Real["OrderService.place_order()"]
Real -->|result| IC2
IC2 -->|result| IC1
IC1 -->|result| Proxy
Proxy -->|result| Caller The MethodCtx object¶
Every interceptor receives a MethodCtx with:
| Attribute | Description |
|---|---|
ctx.instance | The real component instance |
ctx.cls | The component's class |
ctx.method | The bound method being called |
ctx.name | Method name (string) |
ctx.args | Positional arguments |
ctx.kwargs | Keyword arguments |
ctx.container | The owning PicoContainer |
ctx.local | Mutable dict for sharing data between interceptors |
ctx.request_key | The active scope ID (e.g. request ID) |
Common Pitfalls¶
| Pitfall | Fix |
|---|---|
Forgetting to call call_next(ctx) -- the real method never executes. | Always call call_next(ctx) unless you intentionally short-circuit. |
Interceptor is not a @component -- the container cannot resolve it. | Add @component to the interceptor class. |
Applying @intercepted_by to the class instead of individual methods. | @intercepted_by decorates methods, not classes. |
Modifying ctx.args/ctx.kwargs without care -- can break the method signature. | If you modify arguments, create a new MethodCtx or update in place. |
Async interceptor on sync method raises RuntimeError. | Ensure sync methods only have sync interceptors, or guard with asyncio.iscoroutinefunction. |
| Circular dependencies -- interceptor depends on the component it intercepts. | Factor out the dependency or use lazy=True. |