Middleware System

Flokkit Flow’s middleware system provides a powerful way to add cross-cutting concerns to your data processing pipelines without modifying the core logic. Middleware can intercept and modify the processing of data at each node in your flow.

Overview

Middleware in Flokkit Flow follows the chain-of-responsibility pattern, where each middleware can:

  • Inspect or modify input data before processing

  • Inspect or modify output data after processing

  • Handle errors during processing

  • Add metadata or context to the processing pipeline

  • Implement concerns like logging, metrics, rate limiting, and retry logic

Basic Usage

from flow import flow, LoggingMiddleware

# Create middleware instance
logger = LoggingMiddleware()

# Apply to a flow
builder = flow("My Pipeline")
await (
    builder
    .with_middleware(logger)  # Apply to all subsequent nodes
    .source(my_source, int)
    .transform(my_transform, str)
    .to(my_sink)
    .execute()
)

Built-in Middleware

LoggingMiddleware

Logs data as it flows through each node.

from flow import LoggingMiddleware

# Log both inputs and outputs
logger = LoggingMiddleware(log_inputs=True, log_outputs=True)

# Log only outputs
output_logger = LoggingMiddleware(log_inputs=False, log_outputs=True)

Output Example:

[transform_1] Input: 42
[transform_1] Output: 84
[filter_1] Input: 84

MetricsMiddleware

Collects processing metrics for monitoring and analysis.

from flow import MetricsMiddleware

metrics = MetricsMiddleware()

# ... run your pipeline ...

# Get collected metrics
stats = metrics.get_metrics()
print(f"Total processed: {stats['total_processed']}")
print(f"Total errors: {stats['total_errors']}")
print(f"By node: {stats['by_node']}")

Metrics Structure:

{
    'total_processed': 150,
    'total_errors': 2,
    'by_node': {
        'source_1': {'processed': 50, 'errors': 0},
        'transform_1': {'processed': 50, 'errors': 1},
        'sink_1': {'processed': 50, 'errors': 1}
    }
}

ThrottleMiddleware

Adds delays between processing to control throughput.

from flow import ThrottleMiddleware

# Add 100ms delay after each processing
throttle = ThrottleMiddleware(delay_seconds=0.1)

# Useful for:
# - Rate limiting API calls
# - Preventing overwhelming downstream systems
# - Testing backpressure behavior

RetryMiddleware

Automatically retries failed operations with exponential backoff.

from flow import RetryMiddleware

# Retry up to 3 times with exponential backoff
retry = RetryMiddleware(max_attempts=3, backoff=1.0)

# Customize retry behavior
aggressive_retry = RetryMiddleware(
    max_attempts=5,
    backoff=0.5  # Start with 0.5s, then 1s, 2s, 4s, 8s
)

Creating Custom Middleware

To create custom middleware, inherit from the Middleware base class:

from flow import Middleware, ProcessingContext
from typing import Any, Awaitable, Callable

class TimingMiddleware(Middleware):
    """Measure processing time for each node."""
    
    def __init__(self):
        self.timings = {}
    
    async def process(
        self,
        context: ProcessingContext,
        next_middleware: Callable[[ProcessingContext], Awaitable[Any]]
    ) -> Any:
        import time
        
        start = time.time()
        try:
            # Call the next middleware/handler
            result = await next_middleware(context)
            
            # Record timing
            elapsed = time.time() - start
            if context.node_name not in self.timings:
                self.timings[context.node_name] = []
            self.timings[context.node_name].append(elapsed)
            
            return result
        except Exception as e:
            # Still record timing for failed operations
            elapsed = time.time() - start
            print(f"[{context.node_name}] Failed after {elapsed:.3f}s: {e}")
            raise

ProcessingContext

The ProcessingContext provides information about the current processing:

@dataclass
class ProcessingContext:
    node_name: str          # Name of the current node
    node_type: str          # Type of node (source, transform, filter, etc.)
    input_value: Any = None # Input value being processed
    output_value: Any = None # Output value after processing
    error: Optional[Exception] = None  # Any error that occurred
    metadata: Dict[str, Any] = field(default_factory=dict)  # Custom metadata

Middleware Composition

Multiple middleware can be combined:

from flow import flow, LoggingMiddleware, MetricsMiddleware, RetryMiddleware

# Create middleware instances
logger = LoggingMiddleware()
metrics = MetricsMiddleware()
retry = RetryMiddleware(max_attempts=3)

# Apply multiple middleware
builder = flow("Robust Pipeline")
await (
    builder
    .with_middleware(logger, metrics, retry)  # Applied in order
    .source(unreliable_source, dict)
    .transform(process_data, dict)
    .to(save_result)
    .execute()
)

Middleware are executed in the order they are added:

  1. First middleware sees the original input

  2. Each middleware can modify the context

  3. Last middleware is closest to the actual processing

Advanced Patterns

Conditional Middleware

class ConditionalMiddleware(Middleware):
    """Only apply middleware logic based on conditions."""
    
    def __init__(self, condition_fn: Callable[[ProcessingContext], bool]):
        self.condition_fn = condition_fn
    
    async def process(self, context, next_middleware):
        if self.condition_fn(context):
            # Apply special logic
            print(f"Condition met for {context.node_name}")
        
        return await next_middleware(context)

# Only log transform nodes
log_transforms = ConditionalMiddleware(
    lambda ctx: ctx.node_type == "transform"
)

Error Handling Middleware

class ErrorHandlerMiddleware(Middleware):
    """Handle specific errors gracefully."""
    
    def __init__(self, error_handlers: Dict[Type[Exception], Callable]):
        self.error_handlers = error_handlers
    
    async def process(self, context, next_middleware):
        try:
            return await next_middleware(context)
        except Exception as e:
            handler = self.error_handlers.get(type(e))
            if handler:
                return handler(e, context)
            raise

# Usage
error_handler = ErrorHandlerMiddleware({
    ValueError: lambda e, ctx: None,  # Skip bad values
    KeyError: lambda e, ctx: {},      # Return empty dict for missing keys
})

State-Tracking Middleware

class StateMiddleware(Middleware):
    """Track state across processing."""
    
    def __init__(self):
        self.state = {}
        self.processed_count = 0
    
    async def process(self, context, next_middleware):
        self.processed_count += 1
        
        # Add state to context
        context.metadata['global_count'] = self.processed_count
        context.metadata['state'] = self.state
        
        result = await next_middleware(context)
        
        # Update state based on result
        if result and isinstance(result, dict):
            self.state.update(result)
        
        return result

Best Practices

  1. Keep Middleware Focused: Each middleware should have a single responsibility

  2. Avoid Heavy Processing: Middleware should be lightweight to avoid impacting performance

  3. Handle Errors Gracefully: Always consider error cases in your middleware

  4. Use Context Metadata: Store middleware-specific data in context.metadata

  5. Make Middleware Reusable: Design middleware to be configurable and reusable across flows

Performance Considerations

  • Middleware adds overhead to each processing step

  • Use throttling middleware carefully as it directly impacts throughput

  • Metrics collection has minimal overhead but can accumulate memory for long-running flows

  • Retry middleware can significantly increase processing time for unreliable operations

Examples

See the examples/ directory for complete middleware examples:

  • 13_middleware_basics.py - Introduction to middleware

  • 14_custom_middleware.py - Creating custom middleware

  • 15_middleware_composition.py - Combining multiple middleware