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
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.
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 CourseWhy 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:
- Plan the architecture
- Design the data structures
- Identify patterns and abstractions
- Consider edge cases and requirements
- Map out the solution mentally
- Write the core logic yourself
- Implement the critical paths
- Build the skeleton of the solution
- Your brain stays in "creator" mode
- Generate boilerplate code
- Write comprehensive test cases
- Refactor for better patterns
- Add error handling
- Generate documentation
- Optimize specific functions
- 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
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
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 patternsExample 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 signatureHow 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.
# Parse the data
# Parse JSON data from API response, extract user info, # handle missing fields with defaults, return User object
# Fix this
# This function throws KeyError when 'email' is missing # Add error handling to return None instead of crashing
2. Review Every Suggestion Critically
Never blindly accept suggestions. Check for:
- Does this code actually solve my problem?
- Are there any security issues? (SQL injection, XSS, path traversal)
- Does it handle edge cases? (None, empty lists, invalid input)
- Is it efficient for my use case? (O(n) vs O(n²))
- Does it follow my project's conventions?
- 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:
users = [user for user in all_users if user.active == True]
users = [user for user in all_users if user.active]
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 patterns4. 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'
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 needsBest 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 solutionsAutomated 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, -1Step 4: Review it before you keep it
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 openRun 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
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 == -1Step 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."""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 responseStep 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
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
doneExpected 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/helloExpected 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.
- 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