Skip to main content

Handler Development Guide

This guide covers how to create, test, and maintain HTTP handlers in the Aragora server.

Handler Architecture

Handlers are organized into subdirectories by domain:

aragora/server/handlers/
├── admin/ # Admin, billing, dashboard, health, system
├── agents/ # Agent management, calibration, probes
├── auth/ # Authentication, OAuth, SSO
├── debates/ # Debate endpoints, graph, matrix
├── evolution/ # Agent evolution, A/B testing
├── features/ # Audio, broadcast, documents, evidence
├── memory/ # Memory, analytics, learning, insights
├── social/ # Collaboration, notifications, sharing
├── verification/ # Formal verification, proofs
└── utils/ # Shared utilities (rate limiting, etc.)

Creating a New Handler

1. Choose the Right Location

Place your handler in the appropriate subdirectory based on its domain. If no subdirectory fits, consider creating a new one or placing it at root level.

2. Handler Structure

"""
My Feature endpoint handlers.

Endpoints:
- GET /api/myfeature - List items
- GET /api/myfeature/\{id\} - Get item details
- POST /api/myfeature - Create item
"""

from __future__ import annotations

import logging
from typing import Optional

from ..base import (
BaseHandler,
HandlerResult,
error_response,
handle_errors,
json_response,
)
from ..utils.rate_limit import RateLimiter, get_client_ip

logger = logging.getLogger(__name__)

# Rate limiter for this handler (adjust as needed)
_myfeature_limiter = RateLimiter(requests_per_minute=60)


class MyFeatureHandler(BaseHandler):
"""Handler for my feature endpoints."""

ROUTES = [
"/api/myfeature",
]

def can_handle(self, path: str) -> bool:
"""Check if this handler can process the given path."""
return path == "/api/myfeature" or path.startswith("/api/myfeature/")

def handle(self, path: str, query_params: dict, handler) -> Optional[HandlerResult]:
"""Route GET requests."""
# Rate limit check
client_ip = get_client_ip(handler)
if not _myfeature_limiter.is_allowed(client_ip):
return error_response("Rate limit exceeded", 429)

if path == "/api/myfeature":
return self._list_items(query_params)

if path.startswith("/api/myfeature/"):
item_id = path.split("/")[-1]
return self._get_item(item_id)

return None

def handle_post(self, path: str, query_params: dict, handler) -> Optional[HandlerResult]:
"""Route POST requests."""
if path == "/api/myfeature":
return self._create_item(handler)
return None

@handle_errors("list items")
def _list_items(self, query_params: dict) -> HandlerResult:
"""List all items."""
# Implementation here
return json_response({"items": []})

@handle_errors("get item")
def _get_item(self, item_id: str) -> HandlerResult:
"""Get item by ID."""
# Validate item_id
if not item_id or len(item_id) > 100:
return error_response("Invalid item ID", 400)

# Fetch item
item = None # Replace with actual fetch
if not item:
return error_response(f"Item not found: \{item_id\}", 404)

return json_response({"item": item})

@handle_errors("create item")
def _create_item(self, handler) -> HandlerResult:
"""Create a new item."""
body = self.read_json_body(handler)
if body is None:
return error_response("Invalid JSON body", 400)

# Validate and create
return json_response({"created": True}, status=201)

3. Register the Handler

Add to handlers/__init__.py:

from .myfeature import MyFeatureHandler

# Add to ALL_HANDLERS list (order matters - more specific first)
ALL_HANDLERS = [
# ... existing handlers ...
MyFeatureHandler,
]

# Add stability classification
HANDLER_STABILITY = {
# ... existing entries ...
"MyFeatureHandler": Stability.EXPERIMENTAL, # or STABLE, PREVIEW
}

# Add to __all__
__all__ = [
# ... existing exports ...
"MyFeatureHandler",
]

If in a subdirectory, also update the subdirectory's __init__.py:

# mysubdir/__init__.py
from .myfeature import MyFeatureHandler

__all__ = ["MyFeatureHandler"]

Key Patterns

Rate Limiting

Always rate limit public endpoints:

from ..utils.rate_limit import RateLimiter, get_client_ip

_my_limiter = RateLimiter(requests_per_minute=60)

def handle(self, path, query_params, handler):
if not _my_limiter.is_allowed(get_client_ip(handler)):
return error_response("Rate limit exceeded", 429)

You can also use the decorator (supports sync and async handlers). When using handlers that pass headers directly, provide headers=... so the decorator can extract client IPs consistently:

from ..utils.rate_limit import rate_limit

@rate_limit(requests_per_minute=60)
async def handle(self, path, query_params, headers=None):
...

Error Handling

Use the @handle_errors decorator:

from ..base import handle_errors

@handle_errors("operation name")
def _my_method(self, ...) -> HandlerResult:
# Exceptions are caught, logged, and returned as error responses
...

Authentication

For protected endpoints:

from aragora.billing.jwt_auth import extract_user_from_request

def _protected_endpoint(self, handler) -> HandlerResult:
user_store = self._get_user_store()
auth_ctx = extract_user_from_request(handler, user_store)
if not auth_ctx.is_authenticated:
return error_response("Not authenticated", 401)
# Proceed with authenticated user
user_id = auth_ctx.user_id

Reading Request Bodies

# For simple JSON
body = self.read_json_body(handler)
if body is None:
return error_response("Invalid JSON", 400)

# For validated JSON
from ..base import safe_json_parse
body = safe_json_parse(handler.request_body)

Pagination

from ..base import get_int_param

def _list_items(self, query_params: dict) -> HandlerResult:
limit = get_int_param(query_params, "limit", 50)
offset = get_int_param(query_params, "offset", 0)
# Clamp to reasonable values
limit = max(1, min(limit, 100))

items = self._fetch_items(limit=limit, offset=offset)
return json_response({
"items": items,
"limit": limit,
"offset": offset,
"total": self._count_items()
})

Async Handlers

For I/O-intensive operations:

@handle_errors("async operation")
async def handle(self, path: str, query_params: dict, handler, body=None) -> HandlerResult:
result = await self._async_fetch()
return json_response(result)

Testing Handlers

Test File Structure

Create tests/server/handlers/test_myfeature.py:

"""Tests for MyFeature handler."""

import json
import pytest
from unittest.mock import MagicMock

from aragora.server.handlers.myfeature import MyFeatureHandler


@pytest.fixture
def myfeature_handler():
ctx = {"storage": None}
return MyFeatureHandler(ctx)


@pytest.fixture
def mock_http_handler():
mock = MagicMock()
mock.client_address = ("127.0.0.1", 12345)
mock.headers = {"Content-Type": "application/json"}
return mock


class TestMyFeatureRouting:
def test_can_handle_myfeature(self, myfeature_handler):
assert myfeature_handler.can_handle("/api/myfeature") is True

def test_cannot_handle_unknown(self, myfeature_handler):
assert myfeature_handler.can_handle("/api/unknown") is False


class TestListItems:
def test_list_returns_items(self, myfeature_handler, mock_http_handler):
result = myfeature_handler.handle("/api/myfeature", {}, mock_http_handler)
assert result.status_code == 200
body = json.loads(result.body)
assert "items" in body

Running Tests

# Run specific handler tests
pytest tests/server/handlers/test_myfeature.py -v

# Run all handler tests
pytest tests/server/handlers/ -v

# Run with coverage
pytest tests/server/handlers/test_myfeature.py --cov=aragora.server.handlers.myfeature

Handler Stability Levels

  • STABLE: Production-ready, extensively tested, API won't change
  • EXPERIMENTAL: Works but may change, use with awareness
  • PREVIEW: Early access, expect changes and potential issues
  • DEPRECATED: Being phased out, use alternative

Update HANDLER_STABILITY in __init__.py as your handler matures.

Checklist for New Handlers

  • Handler inherits from BaseHandler
  • can_handle() method properly checks paths
  • Rate limiting applied to public endpoints
  • @handle_errors decorator on methods
  • Input validation for all parameters
  • Proper HTTP status codes (200, 201, 400, 401, 404, 429, 500)
  • Handler registered in __init__.py
  • Stability level set in HANDLER_STABILITY
  • Tests created with 80%+ coverage
  • Docstring with endpoint documentation

Common Issues

Import Errors After Moving

If you move a handler to a subdirectory, update relative imports:

# Before (at root)
from .base import BaseHandler

# After (in subdirectory)
from ..base import BaseHandler

Handler Not Being Called

  1. Check can_handle() returns True for your path
  2. Verify handler is in ALL_HANDLERS list
  3. Check handler order - more specific paths should come first

Tests Failing After Refactor

If decorator signature changes, ensure test mocks match:

# If @handle_errors requires argument
@handle_errors("operation") # Correct
@handle_errors # Wrong - missing argument

Secure Handlers

For handlers that deal with sensitive operations, authentication, or require RBAC enforcement, use SecureHandler instead of BaseHandler.

When to Use SecureHandler

Use SecureHandler for:

  • User management operations (create, update, delete users)
  • Admin operations (impersonation, system config)
  • Billing/payment operations
  • Data export/deletion (GDPR compliance)
  • API key management
  • Operations requiring audit trails

SecureHandler Features

SecureHandler provides automatic:

  1. JWT Authentication - Token extraction and verification
  2. RBAC Enforcement - Permission checking via the permission system
  3. Audit Logging - Writes to the immutable audit trail
  4. Security Metrics - Records auth attempts, RBAC decisions, blocked requests
  5. Encryption Support - Built-in field encryption/decryption helpers

Creating a Secure Handler

from aragora.server.handlers.secure import (
SecureHandler,
secure_endpoint,
audit_sensitive_access,
)

class MySecureHandler(SecureHandler):
"""Handler with security features."""

# Resource type for audit logging
RESOURCE_TYPE = "my_resource"

ROUTES = ["/api/secure/myresource"]

@secure_endpoint(permission="myresource.read")
async def handle_get(self, request, auth_context, **kwargs):
"""auth_context is automatically injected and verified."""
return json_response({"user_id": auth_context.user_id})

@secure_endpoint(permission="myresource.write", audit=True)
async def handle_post(self, request, auth_context, **kwargs):
"""This action is logged to the audit trail."""
return json_response({"created": True})

@secure_endpoint(permission="myresource.delete", audit=True, audit_action="delete")
async def handle_delete(self, request, auth_context, resource_id, **kwargs):
"""Custom audit action name."""
return json_response({"deleted": True})

The @secure_endpoint Decorator

@secure_endpoint(
permission="resource.action", # RBAC permission key (optional)
require_auth=True, # Require authentication (default: True)
audit=False, # Log to audit trail (default: False)
audit_action=None, # Custom audit action name
resource_id_param=None, # Parameter containing resource ID
)

Auditing Sensitive Data Access

For endpoints that access PII or secrets, use @audit_sensitive_access:

@audit_sensitive_access("api_key", "read")
async def get_api_key(self, request, auth_context):
"""This logs to audit trail that API key was accessed."""
return json_response({"key": "..."})

Admin Handlers with MFA Enforcement

For admin handlers that require MFA (SOC 2 CC5-01 compliance), use the admin_secure_endpoint decorator:

from aragora.server.handlers.admin.admin import AdminHandler, admin_secure_endpoint

class MyAdminHandler(AdminHandler):
RESOURCE_TYPE = "admin_operation"

@admin_secure_endpoint(permission="admin.users.manage", audit=True)
async def manage_users(self, request, auth_context, **kwargs):
"""
Automatically enforces:
1. JWT authentication
2. Admin or owner role
3. MFA enabled
4. RBAC permission check
5. Audit logging
"""
return json_response({"success": True})

Testing Secure Handlers

When testing secure handlers, mock the RBAC permission check:

from unittest.mock import patch, MagicMock

def _mock_allowed_decision():
"""Create a mock decision that allows access."""
decision = MagicMock()
decision.allowed = True
decision.reason = "Test mock: allowed"
return decision

@patch("aragora.server.handlers.secure.check_permission")
@patch("aragora.server.handlers.utils.auth.get_auth_context")
def test_secure_endpoint(mock_auth, mock_check_permission):
mock_auth.return_value = MagicMock(user_id="user-123", roles={"admin"})
mock_check_permission.return_value = _mock_allowed_decision()

# Your test code here

Checklist for Secure Handlers

  • Handler inherits from SecureHandler (or AdminHandler for admin ops)
  • RESOURCE_TYPE is set for audit logging
  • @secure_endpoint decorator with appropriate permission
  • audit=True for state-changing operations
  • @audit_sensitive_access for PII/secret access
  • Tests mock check_permission to allow/deny access
  • Error handling uses handle_security_error()

Approval Gate Middleware

For high-risk operations that require human approval, use the approval gate:

from aragora.server.middleware import require_approval, OperationRiskLevel

@require_approval(
risk_level=OperationRiskLevel.HIGH,
operation_type="delete_all_data",
description="Delete all user data permanently",
)
async def delete_all_data(request):
"""This endpoint requires human approval before execution."""
# Only reaches here after approval is granted
return await perform_deletion()

Approval States

StateHTTP CodeMeaning
PENDING202Waiting for approval
APPROVED200Proceeds to handler
DENIED403Operation blocked
EXPIRED410Approval timed out

Working with Approvals

from aragora.server.middleware import (
create_approval_request,
get_approval_request,
resolve_approval,
ApprovalState,
)

# Create approval request
approval = await create_approval_request(
operation_type="delete_user",
risk_level=OperationRiskLevel.HIGH,
requested_by="user-123",
metadata={"target_user": "user-456"},
)

# Get pending approvals
pending = await get_pending_approvals(workspace_id="workspace-123")

# Approve/deny
await resolve_approval(
approval_id=approval.id,
state=ApprovalState.APPROVED,
approved_by="admin-789",
notes="Verified deletion request",
)