AI-Powered Development Tools

Code assistants, testing tools, and productivity enhancers.

The AI Development Toolkit

AI-powered development tools are transforming how we write, test, and maintain code. From real-time code completion to automated test generation, these tools can dramatically boost productivity. But remember: these are power tools. They amplify your abilities when used correctly, but they require skill and judgment to use effectively. This lesson shows you how to integrate AI tools into your workflow without losing control.

Categories of AI Development Tools

AI development tools fall into several categories, each solving different problems:

Where Each Tool Category Sits in the Development Cycle

PlanWriteReviewTestDocumentCode assistantsCopilot, CursorReview toolsPR analysisTesting & debugtest generationDoc generators

Figure 1: Most attention goes to the Write step, but the largest wins are usually in Review and Test.

1. Code Assistants

Examples: GitHub Copilot, Cursor, Tabnine, Amazon Q Developer

Real-time code suggestions, autocomplete, and generation as you type. Integrated directly into your editor.

2. Code Analysis & Review

Examples: DeepCode, Snyk, Codacy, SonarQube with AI

Automated code review, security vulnerability detection, and quality analysis.

3. Testing & Debugging

Examples: TestPilot, Mabl, Testim, Sentry AI

AI-generated test cases, intelligent debugging, and error analysis.

4. Documentation & Explanation

Examples: Mintlify, Stenography, Docstring AI

Automatic documentation generation, code explanation, and comment creation.

Focus of this lesson: We'll focus primarily on code assistants since they're the most widely used and have the biggest impact on day-to-day development. The principles you learn here apply to other AI tools as well.

Our Preferred Tool: Claude Code

While we'll cover popular tools like GitHub Copilot, we believe Claude Code is the most capable AI code assistant when used properly. But our preference isn't just about capability, it's about philosophy.

Learn More: Claude Code in Action

Want to see these principles in practice? Anthropic offers a specialized course on using Claude Code effectively.

Take the Course

Why We Prefer Claude Code

1. Quality Over Speed

Claude Code excels at generating high-quality, well-structured code when you provide clear context and requirements. The output quality is consistently superior for complex tasks, architecture decisions, and refactoring.

2. No Constant "Whisper" Interruptions

We deliberately avoid constant autocompletion suggestions. Here's why:

The problem with aggressive autocomplete:

  • Constant suggestions break your flow and concentration
  • You lose focus on architecture and design thinking
  • Temptation to accept suggestions without critical thought
  • Your brain shifts from "architect" mode to "reviewer" mode
  • The tool drives development instead of you driving the tool
3. Our Deliberate Workflow

We use AI assistance intentionally, not constantly:

1THINKno AI
  • Plan the architecture
  • Design the data structures
  • Identify patterns and abstractions
  • Consider edge cases and requirements
  • Map out the solution mentally
2WRITEminimal AI
  • Write the core logic yourself
  • Implement the critical paths
  • Build the skeleton of the solution
  • Your brain stays in "creator" mode
3ACCELERATEClaude Code
  • Generate boilerplate code
  • Write comprehensive test cases
  • Refactor for better patterns
  • Add error handling
  • Generate documentation
  • Optimize specific functions
4REVIEWyour judgment
  • Verify Claude's suggestions
  • Ensure consistency with your design
  • Test thoroughly
  • Maintain architectural integrity

The dots track how much AI is involved, and the shape they make is the whole point: assistance peaks in the middle and drops to zero at both ends. The thinking that starts the work and the judgment that closes it stay yours. Claude is loudest in between, where the work is mechanical.

This approach means we don't achieve maximum speed, but we achieve something more valuable: full confidence in our architecture, design, and future maintainability.

4. The Trade-Off We Accept
What We Give Up:
  • Instant autocompletion
  • Maximum coding speed
  • Constant suggestions
  • Real-time assistance
What We Gain:
  • Deep focus and concentration
  • Architectural clarity
  • Design consistency
  • Maintainable codebases
  • Confidence in decisions
  • Control over the solution
Philosophy: AI should be your assistant, not your autopilot. You remain the architect, the designer, the decision-maker. Claude Code helps you execute your vision faster and better, but the vision comes from you.
Key Insight: Focus Matters More Than Speed

The best developers aren't the fastest typists, they're the best thinkers. Constant AI suggestions optimize for typing speed but can undermine thinking depth. By using AI deliberately (like Claude Code), you optimize for code quality, architectural soundness, and long-term maintainability. These matter far more than shipping code quickly.

GitHub Copilot: The Most Popular Code Assistant

GitHub Copilot is the most widely adopted AI coding assistant. It runs inside VS Code, JetBrains IDEs, and other editors, providing real-time suggestions as you code.

How Copilot Works

Copilot uses a selectable mix of frontier models (from OpenAI, Anthropic's Claude, and Google's Gemini) trained on billions of lines of public code. It considers:

  • Current file context: Your code, imports, function signatures
  • Comments: Natural language descriptions of what you want
  • Surrounding code: Related files in your project
  • Patterns: Common coding patterns from its training

One Suggestion, End to End

YouEditorAssistantModeltype a line or commentfile + nearby contextassemble promptcompletion requestcandidate codeinline suggestionghost textaccept, edit, or reject

Figure 2: The assistant never sees your whole repository, only the context the editor chooses to send. That is why file layout and naming change suggestion quality.

Real-World Copilot Examples

Example 1: Function from Comment

# You type:
# Function to validate email addresses using regex

# Copilot suggests:
import re

def validate_email(email):
    """
    Validate email address format.

    Args:
        email: Email address to validate

    Returns:
        bool: True if valid email format, False otherwise
    """
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))


# Result: Complete, working function from a single comment!

Example 2: Completing Boilerplate

# You type:
class User:
    def __init__(self, name, email):

# Copilot suggests:
        self.name = name
        self.email = email
        self.created_at = datetime.now()
        self.id = str(uuid.uuid4())

    def __repr__(self):
        return f"User(name={self.name}, email={self.email})"

    def to_dict(self):
        return {
            "id": self.id,
            "name": self.name,
            "email": self.email,
            "created_at": self.created_at.isoformat()
        }


# Copilot often suggests standard methods and patterns

Example 3: Test Generation

# You have this function:
def calculate_discount(price, discount_percent):
    """Calculate final price after discount."""
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Discount must be between 0 and 100")
    return price * (1 - discount_percent / 100)


# You type:
def test_calculate_discount():

# Copilot suggests complete test:
    # Test normal discount
    assert calculate_discount(100, 20) == 80
    assert calculate_discount(50, 10) == 45

    # Test edge cases
    assert calculate_discount(100, 0) == 100
    assert calculate_discount(100, 100) == 0

    # Test invalid input
    with pytest.raises(ValueError):
        calculate_discount(100, -10)

    with pytest.raises(ValueError):
        calculate_discount(100, 150)


# Copilot often generates comprehensive tests from function signature
Critical reminder: Copilot suggestions are not guaranteed to be correct. Always review, test, and understand the code before accepting it. Treat it like code review from a junior developer.

How to Use Code Assistants Effectively

Getting the most from AI code assistants requires developing good habits:

1. Write Clear, Descriptive Comments

Comments are prompts for your code assistant. The better your comment, the better the suggestion.

Vague
# Parse the data
Which data? Into what? The assistant has to guess every decision.
Specific
# Parse JSON data from API response, extract user info,
# handle missing fields with defaults, return User object
Names the input, the transformation, the failure mode, and the return type.
No context
# Fix this
Fix what? The assistant cannot see the bug you are looking at.
With context
# This function throws KeyError when 'email' is missing
# Add error handling to return None instead of crashing
States the symptom and the desired behavior, so there is one obvious fix.

2. Review Every Suggestion Critically

Never blindly accept suggestions. Check for:

  1. Does this code actually solve my problem?
  2. Are there any security issues? (SQL injection, XSS, path traversal)
  3. Does it handle edge cases? (None, empty lists, invalid input)
  4. Is it efficient for my use case? (O(n) vs O(n²))
  5. Does it follow my project's conventions?
  6. Are there better ways to do this?

Question 6 catches things the others miss. A suggestion can be correct, secure, and still not what you would have written:

What the assistant suggested
users = [user for user in all_users if user.active == True]
Correct, and it runs. But == True on a boolean is redundant, and it would not pass review.
What you should write instead
users = [user for user in all_users if user.active]
Same behavior, says what it means. Recognizing this is the skill the checklist is training.
Security warning: AI assistants sometimes suggest code with security vulnerabilities. They've learned from public repositories where security issues are common. Always audit suggestions for injection attacks, authentication bypasses, and data exposure.

3. Provide Context Through Code Structure

Code assistants learn from your existing code. Set the pattern you want them to follow.

# If you want specific patterns, show examples first:

# Example 1: You want type hints
def get_user(user_id: int) -> Optional[User]:
    """Fetch user by ID."""
    pass

# Now when you start a new function:
def get_product(product_id:
# Copilot will suggest: int) -> Optional[Product]:


# Example 2: You want specific error handling
def fetch_data(url: str) -> dict:
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.RequestException as e:
        logger.error(f"Failed to fetch {url}: {e}")
        return {}

# Now new functions will follow similar patterns

4. Iterate and Refine Suggestions

Don't settle for the first suggestion. You can guide the AI to better solutions.

1. First attempt: you get basic sorting

# Function to sort users by age

2. Add the ordering and the awkward case

# Function to sort users by age in descending order,
# handling None values by putting them at the end

3. Spell out the contract

# Function to sort users by age in descending order
# - Put None ages at the end
# - Return a new list (don't modify original)
# - Add type hints
# - Handle empty list gracefully

Each step removes a decision the assistant would otherwise make for you. By step 3 the comment specifies ordering, null handling, mutation, and types, which is roughly the docstring you would have written anyway.

5. Learn from Good Suggestions

When Copilot suggests something clever or uses a library feature you didn't know, take a moment to understand it.

# You might not know about collections.Counter
# Copilot suggests:
from collections import Counter

def find_most_common_word(text):
    words = text.lower().split()
    word_counts = Counter(words)
    return word_counts.most_common(1)[0]

# Take a moment to learn:
# - What is Counter?
# - How does most_common() work?
# - When should I use Counter vs manual counting?

# This expands your Python knowledge!

Cursor: The AI-Native Editor

Cursor is a fork of VS Code designed from the ground up for AI-assisted development. It goes beyond simple autocomplete to offer more sophisticated AI features.

Key Features

1. Chat with Your Codebase

Ask questions about your entire project, not just the current file.

# Example queries in Cursor:
"Where is user authentication implemented?"
"Show me all API endpoints that handle payments"
"How does the caching system work?"
"Find functions that query the database without using parameterized queries"

# Cursor scans your entire codebase to answer
2. Edit with Natural Language

Select code and describe changes in plain English.

# Select a function, then prompt:
"Add type hints and docstring"
"Refactor to use list comprehension"
"Add error handling for network timeouts"
"Convert this to async/await"

# Cursor modifies the code directly
3. Codebase-Aware Suggestions

Unlike basic autocomplete, Cursor understands your project structure, imports, and existing patterns.

# It knows about your custom classes:
user = User.query.filter_by(email=
# Suggests: email).first()  # Using your actual User class

# It understands your project conventions:
# If you use 'logger.info' everywhere, it won't suggest 'print'
Cursor vs Copilot: Copilot focuses on line-by-line suggestions. Cursor offers whole-project understanding and multi-file edits. Many developers use both: Copilot for autocomplete, Cursor for complex refactoring.

AI-Powered Testing and Debugging

AI tools can help generate tests, find bugs, and explain errors. Here's how to use them effectively:

Automated Test Generation

Use AI to generate initial test cases, then refine them based on your actual requirements.

# Prompt to AI (ChatGPT, Claude, or Copilot):
"""Generate comprehensive pytest tests for this function:

def parse_user_input(data: dict) -> User:
    '''
    Parse user data from API input.
    Required fields: email, username
    Optional fields: age, bio
    '''
    if not data.get('email') or not data.get('username'):
        raise ValueError("Missing required fields")

    return User(
        email=data['email'],
        username=data['username'],
        age=data.get('age'),
        bio=data.get('bio', '')
    )

Include tests for:
- Valid input with all fields
- Valid input with only required fields
- Missing required fields (should raise ValueError)
- Invalid data types
- Edge cases (empty strings, None values, very long inputs)
"""

# AI generates:
import pytest

def test_parse_user_input_with_all_fields():
    data = {
        'email': 'user@example.com',
        'username': 'testuser',
        'age': 25,
        'bio': 'Hello world'
    }
    user = parse_user_input(data)
    assert user.email == 'user@example.com'
    assert user.username == 'testuser'
    assert user.age == 25
    assert user.bio == 'Hello world'

def test_parse_user_input_required_only():
    data = {
        'email': 'user@example.com',
        'username': 'testuser'
    }
    user = parse_user_input(data)
    assert user.email == 'user@example.com'
    assert user.username == 'testuser'
    assert user.age is None
    assert user.bio == ''

def test_parse_user_input_missing_email():
    data = {'username': 'testuser'}
    with pytest.raises(ValueError, match="Missing required fields"):
        parse_user_input(data)

def test_parse_user_input_missing_username():
    data = {'email': 'user@example.com'}
    with pytest.raises(ValueError, match="Missing required fields"):
        parse_user_input(data)

def test_parse_user_input_empty_email():
    data = {'email': '', 'username': 'testuser'}
    with pytest.raises(ValueError):
        parse_user_input(data)

# You review and add more specific edge cases based on your needs

Best practice: Use AI to generate the test skeleton, then customize with your domain-specific knowledge and edge cases.

Intelligent Error Explanation

When you encounter confusing errors, AI can explain them in plain English and suggest fixes.

# You get this error:
TypeError: 'NoneType' object is not subscriptable

# Your code:
def get_first_item(data):
    return data[0]

result = get_first_item(None)

# Prompt to AI:
"""I'm getting this error: TypeError: 'NoneType' object is not subscriptable

My code:
def get_first_item(data):
    return data[0]

result = get_first_item(None)

What's causing this and how do I fix it?
"""

# AI explains:
"""
The error occurs because you're trying to use indexing ([0]) on None.

Explanation:
- 'NoneType' means the variable is None
- 'not subscriptable' means you can't use [] on it
- Your function is called with None, but tries to access data[0]

Fixes:

Option 1: Add validation
def get_first_item(data):
    if data is None or len(data) == 0:
        return None
    return data[0]

Option 2: Use try-except
def get_first_item(data):
    try:
        return data[0]
    except (TypeError, IndexError):
        return None

Option 3: Ensure valid input at call site
if data is not None:
    result = get_first_item(data)
"""

# Now you understand the problem AND have solutions

Automated Code Review

Use AI for first-pass code review before human review. Good for catching common issues.

# Prompt template for code review:
"""Review this Python code for:
1. Bugs and edge cases
2. Security vulnerabilities
3. Performance issues
4. Code quality and readability
5. Best practices

Code:
[paste your code]

Be specific about line numbers and provide fixes.
"""

# The AI will catch things like:
# - Missing error handling
# - SQL injection risks
# - Inefficient loops
# - Unclear variable names
# - Potential race conditions

# Example output:
"""
Line 5: SQL Injection vulnerability
    user_id = request.args.get('id')
    query = f"SELECT * FROM users WHERE id = {user_id}"

    Fix: Use parameterized queries
    query = "SELECT * FROM users WHERE id = ?"
    cursor.execute(query, (user_id,))

Line 12: Performance issue
    results = [x for x in data if expensive_check(x)]

    This calls expensive_check() on every item. If data is large,
    consider filtering at database level or caching results.
"""

Integrating AI Tools into Your Workflow

Here's a practical workflow that balances AI assistance with manual control:

Development Workflow with AI Tools

Phase 1: Planning (Manual)
  • Define requirements and architecture yourself
  • Plan the approach before writing code
  • Identify edge cases and error scenarios
  • Why manual: AI can't understand business requirements
Phase 2: Implementation (AI-Assisted)
  • Write clear comments describing what you want
  • Let Copilot suggest implementation
  • Review and modify suggestions
  • Focus on logic, let AI handle boilerplate
Phase 3: Testing (AI-Assisted)
  • Generate initial test cases with AI
  • Add domain-specific tests manually
  • Run tests, use AI to explain failures
  • Iterate until all tests pass
Phase 4: Review (AI + Manual)
  • Run AI code review for first pass
  • Fix obvious issues (security, bugs)
  • Manual review for architecture and business logic
  • Human review remains critical
Phase 5: Documentation (AI-Assisted)
  • Generate docstrings and comments with AI
  • Review for accuracy
  • Write high-level docs (architecture, design decisions) manually
  • AI handles low-level documentation

Common Pitfalls When Using AI Tools

1. Accepting Without Understanding

Problem: Pressing Tab to accept suggestions without reading the code.

Solution: Always read the entire suggestion. If you don't understand it, don't use it. Learning happens when you understand, not when you copy.

2. Skipping Security Review

Problem: Assuming AI-generated code is secure by default.

Solution: Always check for injection vulnerabilities, authentication bypasses, data exposure, and other OWASP Top 10 issues. AI learned from public code, which often contains security flaws.

3. Over-Reliance on Suggestions

Problem: Becoming unable to code without AI assistance.

Solution: Regularly practice coding without AI. Solve algorithm problems, build small projects from scratch. AI should enhance your skills, not replace them.

4. Trusting Test Generation Blindly

Problem: Using AI-generated tests without verifying they test the right things.

Solution: AI doesn't know your business requirements. Review tests to ensure they cover critical paths, edge cases, and failure modes specific to your domain.

5. Ignoring Privacy and Legal Issues

Problem: Sending proprietary code to AI services without considering IP issues.

Solution: Check your company's policy on AI tools. Some services store your code for training. Use enterprise versions with privacy guarantees for commercial projects.

6. Not Customizing to Your Standards

Problem: Accepting suggestions that don't match your team's coding standards.

Solution: Modify suggestions to match your project conventions. AI learns from many projects but doesn't know your team's specific standards.

Best Practices for AI-Powered Development

1. Maintain Strong Fundamentals

AI tools are most effective when you have solid programming fundamentals. Keep learning data structures, algorithms, design patterns, and best practices. AI amplifies your skills, it doesn't create them.

2. Use AI for Boilerplate, Think for Logic

Let AI handle repetitive code (getters/setters, standard CRUD operations, test skeletons). You focus on business logic, architecture decisions, and complex problem-solving.

3. Always Review for Security

Make security review a habit. Check every AI-generated function for injection vulnerabilities, authentication issues, and data exposure. This is non-negotiable.

4. Combine AI with Human Review

Use AI for initial code review, but always have human review for architecture, business logic, and critical systems. AI catches syntax errors and common bugs; humans catch design flaws.

5. Test Everything

AI-generated code might work for happy paths but fail on edge cases. Write comprehensive tests, including negative test cases and boundary conditions.

6. Document AI-Assisted Decisions

When AI suggests a pattern or solution you weren't familiar with, add a comment explaining why that approach was chosen. This helps future maintainers (including future you).

Complete Example: AI-Assisted Feature Development

Let's walk through building a complete feature using AI tools effectively:

Task: Build an API Rate Limiter

We need to limit API requests to 100 per minute per user to prevent abuse.

Setup: everything below runs against a real Redis

Start a throwaway container so you can run each step as you read it, and remove it when you are finished.

# Start Redis on the default port
docker run -d --name ratelimit-redis -p 6379:6379 redis:8-alpine

# Python dependencies
pip install redis flask pytest

# ... work through the steps below ...

# Tear it down when you're done
docker rm -f ratelimit-redis

Open one connection now. Every runnable snippet below reuses this redis_client:

import redis

redis_client = redis.Redis(host="localhost", port=6379)
print("ping ->", redis_client.ping())
Expected Output:
ping -> True
Step 1: Plan manually

No AI yet. The requirements come from your domain, and no assistant can infer them:

  • Track requests per user
  • Limit: 100 requests per 60 seconds
  • Return a 429 status when the limit is exceeded
  • Store state in Redis, so it works across processes
  • Reset the counter as the window slides
Step 2: Write the comment that will drive the suggestion
# Rate limiter using Redis
# - Track requests per user in Redis
# - Sliding window: count requests in the last 60 seconds
# - Return True if allowed, False if rate limited
# - Handle Redis connection errors gracefully
Step 3: Let the assistant suggest an implementation
import time
from typing import Tuple

import redis


def check_rate_limit(user_id: str, redis_client: redis.Redis) -> Tuple[bool, int]:
    """Check if user has exceeded rate limit.

    Returns:
        Tuple of (allowed: bool, remaining: int)
    """
    key = f"rate_limit:{user_id}"
    current_time = time.time()
    window = 60  # seconds
    limit = 100

    try:
        # Remove old entries outside the window
        redis_client.zremrangebyscore(key, 0, current_time - window)

        # Count current requests in window
        current_count = redis_client.zcard(key)

        if current_count < limit:
            redis_client.zadd(key, {str(current_time): current_time})
            redis_client.expire(key, window)
            return True, limit - current_count - 1
        else:
            return False, 0

    except redis.RedisError as e:
        # Log error, but allow the request through (fail open)
        print(f"Redis error: {e}")
        return True, -1
Step 4: Review it before you keep it
What it got rightSorted sets are the right structure for a sliding window, old entries are trimmed, Redis errors are caught, and the remaining count is returned for response headers.
The bug it left inThe count and the write are separate round trips. Two concurrent requests can both read 99, both decide they are allowed, and both write, taking the user to 101.
Step 5: Ask for the fix

Prompt: "This has a race condition. Use a Redis pipeline so the trim, add, and count happen atomically."

def check_rate_limit_improved(user_id: str, redis_client: redis.Redis) -> Tuple[bool, int]:
    """Rate limit with atomic operations."""
    key = f"rate_limit:{user_id}"
    current_time = time.time()
    window = 60
    limit = 100

    try:
        # redis-py pipelines default to transaction=True, which wraps the
        # commands in MULTI/EXEC, so no other client interleaves with them.
        pipe = redis_client.pipeline()
        pipe.zremrangebyscore(key, 0, current_time - window)
        pipe.zadd(key, {str(current_time): current_time})
        pipe.expire(key, window)
        pipe.zcard(key)

        results = pipe.execute()
        current_count = results[-1]   # last result is the count

        if current_count <= limit:
            return True, limit - current_count
        else:
            # Over the limit: take back the request we just added
            redis_client.zrem(key, str(current_time))
            return False, 0

    except redis.RedisError as e:
        print(f"Redis error: {e}")
        return True, -1  # fail open

Run it against the container and it does hold the line at exactly 100:

redis_client.delete("rate_limit:alice")   # start from a clean window

for i in range(1, 106):
    allowed, remaining = check_rate_limit_improved("alice", redis_client)
    if not allowed:
        print(f"first rejection at request #{i}")
        break

print("entries in the window:", redis_client.zcard("rate_limit:alice"))
Expected Output:
first rejection at request #101
entries in the window: 100
The review is not finished, though. The assistant fixed the race it was told about and left a second one. The sorted-set member is str(current_time) , so two requests that land on the same float timestamp are the same member: the second zadd overwrites the first instead of adding to it, and the counter does not move.
redis_client.delete("rate_limit:bob")

key, t = "rate_limit:bob", time.time()
for attempt in (1, 2):
    pipe = redis_client.pipeline()
    pipe.zadd(key, {str(t): t})
    pipe.zcard(key)
    print(f"request {attempt}: counter now {pipe.execute()[-1]}")
Expected Output:
request 1: counter now 1
request 2: counter now 1

Two requests, one counted. The fix is to make each member unique, for example f"{current_time}:{uuid4().hex}" , keeping the score as the timestamp so the window trim still works. This is the point of the whole exercise: the assistant answered exactly the question you asked and nothing more, so the second bug survives until a human goes looking for it.

Step 6: Generate tests

Prompt: "Generate pytest tests for check_rate_limit_improved." These mock Redis, so they run without the container.

from unittest.mock import Mock

import redis


def test_rate_limit_allows_under_limit():
    """Requests are allowed when under the limit."""
    mock_redis = Mock()
    mock_redis.pipeline.return_value.execute.return_value = [None, None, None, 50]

    allowed, remaining = check_rate_limit_improved("user123", mock_redis)

    assert allowed is True
    assert remaining == 50


def test_rate_limit_blocks_over_limit():
    """Requests are blocked once the count passes the limit."""
    mock_redis = Mock()
    mock_redis.pipeline.return_value.execute.return_value = [None, None, None, 101]

    allowed, remaining = check_rate_limit_improved("user123", mock_redis)

    assert allowed is False
    assert remaining == 0


def test_rate_limit_handles_redis_error():
    """A Redis outage fails open rather than locking everyone out."""
    mock_redis = Mock()
    mock_redis.pipeline.side_effect = redis.RedisError("Connection failed")

    allowed, remaining = check_rate_limit_improved("user123", mock_redis)

    assert allowed is True
    assert remaining == -1
Step 7: Add the tests the assistant could not write

Mocks only prove the code does what you already assumed. The behaviors that actually matter need the real thing, and they are yours to specify:

def test_sliding_window_expires_old_requests(redis_client):
    """After the window passes, earlier requests stop counting."""
    # needs real Redis, and a clock you control

def test_two_requests_in_the_same_instant_both_count(redis_client):
    """Regression test for the timestamp-collision bug found above."""

def test_limit_is_per_user(redis_client):
    """One user hitting the limit must not affect anyone else."""
Decide "fail open" deliberately. When Redis is unreachable this code lets every request through. For a limiter guarding cost or abuse, that hands an attacker a bypass: knock over Redis and the limit disappears. Failing closed trades that for an outage. Either can be right, but it is a business decision, and an assistant will pick one for you silently if you do not.
Step 8: Wire it into the API
from flask import Flask, g, jsonify, request

app = Flask(__name__)
redis_client = redis.Redis(host="localhost", port=6379)


@app.before_request
def rate_limit_middleware():
    user_id = request.headers.get("X-User-ID")
    if not user_id:
        return jsonify({"error": "Missing user ID"}), 400

    allowed, remaining = check_rate_limit_improved(user_id, redis_client)

    if not allowed:
        return jsonify({"error": "Rate limit exceeded", "retry_after": 60}), 429

    g.rate_limit_remaining = remaining   # stash for the response hook


@app.after_request
def add_rate_limit_headers(response):
    remaining = getattr(g, "rate_limit_remaining", None)
    if remaining is not None:
        response.headers["X-RateLimit-Limit"] = "100"
        response.headers["X-RateLimit-Remaining"] = str(remaining)
    return response
Middleware alone serves nothing. Those two hooks run around every request, but the app has no route yet, so every path would still return 404. You need at least one endpoint to see the limiter work.
Step 9: Run it and watch the limit engage

Put the whole thing in app.py: the limiter from Step 5, the two hooks from Step 8, and one route to call.

# app.py
import time
from typing import Tuple

import redis
from flask import Flask, g, jsonify, request

app = Flask(__name__)
redis_client = redis.Redis(host="localhost", port=6379)


def check_rate_limit_improved(user_id: str, redis_client: redis.Redis) -> Tuple[bool, int]:
    """Rate limit with atomic operations (Step 5)."""
    key = f"rate_limit:{user_id}"
    current_time = time.time()
    window = 60
    limit = 100

    try:
        pipe = redis_client.pipeline()
        pipe.zremrangebyscore(key, 0, current_time - window)
        pipe.zadd(key, {str(current_time): current_time})
        pipe.expire(key, window)
        pipe.zcard(key)

        results = pipe.execute()
        current_count = results[-1]

        if current_count <= limit:
            return True, limit - current_count
        else:
            redis_client.zrem(key, str(current_time))
            return False, 0

    except redis.RedisError as e:
        print(f"Redis error: {e}")
        return True, -1


@app.before_request
def rate_limit_middleware():
    user_id = request.headers.get("X-User-ID")
    if not user_id:
        return jsonify({"error": "Missing user ID"}), 400

    allowed, remaining = check_rate_limit_improved(user_id, redis_client)

    if not allowed:
        return jsonify({"error": "Rate limit exceeded", "retry_after": 60}), 429

    g.rate_limit_remaining = remaining


@app.after_request
def add_rate_limit_headers(response):
    remaining = getattr(g, "rate_limit_remaining", None)
    if remaining is not None:
        response.headers["X-RateLimit-Limit"] = "100"
        response.headers["X-RateLimit-Remaining"] = str(remaining)
    return response


@app.get("/hello")
def hello():
    return jsonify({"message": "within your limit"})

With the Redis container from the setup step still running, start the server:

flask --app app run --port 5000
Expected Output:
 * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
Name the module explicitly. Bare flask run works too, but only by guessing: with no --app it searches the current directory for app.py or wsgi.py and imports whichever it finds first. Run it in a directory that already contains an unrelated app.py and Flask imports that file instead, executing whatever is at its module level. The failure surfaces as a stack trace from a library you never called, which is a genuinely confusing way to spend twenty minutes. --app app says which module you meant and removes the guess.

Now exercise it from another terminal. A normal request comes back with the budget in the headers:

curl -i -H "X-User-ID: alice" http://127.0.0.1:5000/hello
Expected Output:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
{"message":"within your limit"}

Omit the header and the middleware rejects it before your route ever runs:

curl -i http://127.0.0.1:5000/hello
Expected Output:
HTTP/1.1 400 BAD REQUEST
{"error":"Missing user ID"}

Then spend the whole budget. Reset the window first so the count starts at zero:

docker exec ratelimit-redis redis-cli DEL rate_limit:alice

for i in $(seq 1 105); do
  code=$(curl -s -o /dev/null -w '%{http_code}' \
         -H "X-User-ID: alice" http://127.0.0.1:5000/hello)
  if [ "$code" = "429" ]; then echo "request #$i -> 429"; break; fi
done
Expected Output:
request #101 -> 429

Request 101 is the first to be turned away, matching what you measured directly against Redis in Step 5. Further calls stay blocked:

curl -i -H "X-User-ID: alice" http://127.0.0.1:5000/hello
Expected Output:
HTTP/1.1 429 TOO MANY REQUESTS
{"error":"Rate limit exceeded","retry_after":60}

And the limit is per user, not global, which is the requirement from Step 1 that is easiest to get wrong and easiest to check:

curl -s -o /dev/null -w '%{http_code}\n' \
     -H "X-User-ID: bob" http://127.0.0.1:5000/hello
Expected Output:
200

Alice is locked out, Bob is unaffected, and the window releases Alice sixty seconds after her first request. That last part is worth waiting out once: it is the only behavior here that no unit test in Step 6 actually covers.

Key lessons from this example:
  • Planning and requirements remained manual (AI can't replace domain knowledge)
  • AI generated initial implementation quickly
  • You identified the race condition (requires understanding of concurrency)
  • AI helped improve the solution based on your feedback
  • Tests were AI-generated but reviewed and extended
  • Integration with the framework was manual

Key Takeaways

  • AI tools boost productivity - but only when you have strong fundamentals to guide them
  • Code assistants learn from context - write clear comments and establish patterns
  • Always review suggestions - check for bugs, security issues, and edge cases
  • Use AI for boilerplate - let it handle repetitive code while you focus on logic
  • Test AI-generated code - it works for happy paths but may fail on edge cases
  • Security is your responsibility - AI often suggests insecure code from public repos
  • Combine AI with human review - AI catches common issues, humans catch design flaws
  • Learn from good suggestions - when AI shows you something clever, understand it
  • Maintain your skills - practice coding without AI to stay sharp
  • AI amplifies abilities - strong developers get stronger, weak developers create disasters faster
What's Next?

You now know how to use AI development tools effectively. In the next lessons, we'll cover:

  • Building AI Applications - Integrating LLMs and ML models into your projects
  • MLOps & Deployment - Running AI systems in production
  • Ethics & Best Practices - Responsible AI development and avoiding bias