Handle
Connecting…
Back to Workshop
Module 10 ~20 min

Make It Bulletproof

Add error handling, retry logic, structured logging, context window management, and rate limiting to make your agent production-ready.

What You'll Build

Error Handling

Graceful failures with retries

Structured Logs

Debug the agent loop effectively

Context Window

Manage token limits gracefully

Rate Limiting

Protect APIs from overuse

Step by Step

Step 10a

Error Handling & Retry Logic

Network calls fail. LLMs time out. MCP servers crash. Wrap your tool calls and LLM requests with retry logic and proper error handling so the agent degrades gracefully.

services/agent/app.py Python
import asyncio
import logging

logger = logging.getLogger("agent")

async def call_mcp_tool_with_retry(
    tool_name: str,
    arguments: dict,
    max_retries: int = 3,
    backoff: float = 1.0,
) -> str:
    """Call an MCP tool with exponential backoff retry."""
    for attempt in range(max_retries):
        try:
            result = await call_mcp_tool(tool_name, arguments)
            return result
        except httpx.TimeoutException:
            wait = backoff * (2 ** attempt)
            logger.warning(
                f"Tool {tool_name} timed out (attempt {attempt + 1}/{max_retries}), "
                f"retrying in {wait}s..."
            )
            await asyncio.sleep(wait)
        except httpx.HTTPStatusError as e:
            logger.error(f"Tool {tool_name} returned {e.response.status_code}")
            return f"Error: tool {tool_name} failed with status {e.response.status_code}"
        except Exception as e:
            logger.error(f"Unexpected error calling {tool_name}: {e}")
            return f"Error: tool {tool_name} failed unexpectedly"

    return f"Error: tool {tool_name} timed out after {max_retries} retries"

Important: Always return error messages as strings rather than raising exceptions. The LLM can read the error message and decide what to do — retry with different parameters, try a different tool, or tell the user what went wrong.

Step 10b

Structured Logging

When debugging an agentic loop, you need to see every step: user query, LLM response, tool calls, tool results, and the final answer. Structured JSON logs make this searchable.

services/agent/app.py Python
import logging
import json
import time

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s %(message)s'
)
logger = logging.getLogger("agent")

async def process_query(query: str, session_id: str = "default") -> str:
    start = time.time()
    logger.info(json.dumps({
        "event": "query_start",
        "session_id": session_id,
        "query": query[:200],  # Truncate for logging
    }))

    tools = await load_tools_from_mcp()
    messages = [{"role": "user", "content": query}]
    iteration = 0

    while True:
        iteration += 1
        response = await client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools or None,
        )
        choice = response.choices[0]

        if not choice.message.tool_calls:
            duration = time.time() - start
            logger.info(json.dumps({
                "event": "query_complete",
                "session_id": session_id,
                "iterations": iteration,
                "duration_s": round(duration, 2),
                "tokens": response.usage.total_tokens,
            }))
            return choice.message.content

        # Log each tool call
        for tc in choice.message.tool_calls:
            logger.info(json.dumps({
                "event": "tool_call",
                "tool": tc.function.name,
                "args": tc.function.arguments,
                "iteration": iteration,
            }))

View logs in real time with Docker Compose:

Terminal bash
# Follow agent logs with timestamps
docker compose logs agent -f --timestamps

# Filter for tool calls only
docker compose logs agent | grep tool_call

# Show logs from all services interleaved
docker compose logs -f
Step 10c

Context Window Management

Every message in the conversation eats tokens. Tool results can be especially large. Without management, you'll hit the context limit and get errors. Add a simple token-aware truncation strategy.

services/agent/app.py Python
import tiktoken

# Rough token counter (works for most OpenAI models)
def count_tokens(messages: list[dict], model: str = "gemini-3.5-flash-lite") -> int:
    """Estimate token count for a list of messages."""
    try:
        enc = tiktoken.encoding_for_model(model)
    except KeyError:
        enc = tiktoken.get_encoding("cl100k_base")

    token_count = 0
    for msg in messages:
        token_count += 4  # message overhead
        for key, val in msg.items():
            if isinstance(val, str):
                token_count += len(enc.encode(val))
    return token_count

MAX_CONTEXT_TOKENS = 8000  # Leave room for the response

def trim_messages(messages: list[dict], max_tokens: int = MAX_CONTEXT_TOKENS) -> list[dict]:
    """Drop oldest messages (except system) to stay within token budget."""
    while count_tokens(messages) > max_tokens and len(messages) > 2:
        # Keep the first message (system prompt) and remove the second
        messages.pop(1)
    return messages

Key insight: Always keep the system prompt (first message) and the latest user message. Drop the oldest conversation turns first. For tool results, consider summarizing long responses before adding them to the context.

Step 10d

Truncate Large Tool Results

Some tools return huge payloads — a news API might send back 50 articles, or a database query could return thousands of rows. Truncate before passing to the LLM.

services/agent/app.py Python
MAX_TOOL_RESULT_CHARS = 4000  # ~1000 tokens

def truncate_result(result: str, max_chars: int = MAX_TOOL_RESULT_CHARS) -> str:
    """Truncate tool result to fit within context budget."""
    if len(result) <= max_chars:
        return result
    return result[:max_chars] + f"\n\n[Truncated: showing {max_chars}/{len(result)} chars]"

# Use it when adding tool results to messages:
messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": truncate_result(result)
})
Step 10e

Rate Limiting

Protect your LLM API budget and external APIs from runaway requests. Add rate limiting at the agent level to cap requests per session and globally.

services/agent/app.py Python
from collections import defaultdict
import time

class RateLimiter:
    """Simple sliding window rate limiter."""
    def __init__(self, max_requests: int = 10, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests: dict[str, list[float]] = defaultdict(list)

    def check(self, key: str) -> bool:
        """Returns True if the request is allowed."""
        now = time.time()
        # Remove expired timestamps
        self.requests[key] = [
            t for t in self.requests[key]
            if now - t < self.window
        ]
        if len(self.requests[key]) >= self.max_requests:
            return False
        self.requests[key].append(now)
        return True

rate_limiter = RateLimiter(max_requests=10, window_seconds=60)

@app.post("/query")
async def handle_query(request: QueryRequest):
    if not rate_limiter.check(request.session_id):
        return {
            "error": "Rate limit exceeded. Please wait before sending more queries.",
            "retry_after_seconds": 60
        }
    result = await process_query(request.query, request.session_id)
    return {"response": result}
Step 10f

Guard the Agentic Loop

An LLM can get stuck in a tool-calling loop — calling the same tool over and over. Add a maximum iteration limit to prevent runaway costs.

services/agent/app.py Python
MAX_ITERATIONS = 10  # Safety limit for the agentic loop

async def process_query(query: str) -> str:
    tools = await load_tools_from_mcp()
    messages = [{"role": "user", "content": query}]

    for iteration in range(MAX_ITERATIONS):
        response = await client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools or None,
        )
        choice = response.choices[0]

        if not choice.message.tool_calls:
            return choice.message.content

        # Trim context if getting too large
        messages = trim_messages(messages)

        # Execute tool calls...
        messages.append(choice.message.model_dump())
        for tc in choice.message.tool_calls:
            args = json.loads(tc.function.arguments)
            result = await call_mcp_tool_with_retry(tc.function.name, args)
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": truncate_result(result)
            })

    # If we hit the limit, return a helpful message
    logger.warning(f"Hit max iterations ({MAX_ITERATIONS}) for query: {query[:100]}")
    return "I'm sorry, I wasn't able to complete this request. Please try rephrasing your question."

Production checklist: With these patterns in place, your agent handles network failures, stays within token limits, prevents runaway loops, and logs everything you need to debug issues. This is the foundation for any production AI agent.

What You've Built

Retry logic with exponential backoff for MCP tool calls
Structured JSON logging for debugging the agentic loop
Context window management with token counting and message trimming
Rate limiting per session to protect API budgets
Loop guard to prevent runaway tool-calling iterations

How was this module?

Your feedback helps us improve the workshop.

Submitting as anonymous

Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.

Local Models (Ollama) Workshop Overview