The whole curriculum in one place β read the theory anywhere (phone-friendly), tap the quizzes for instant feedback, and bring the π€ with-Claude exercises to our sessions.
π Quiz β tap, instant feedback (great on phone) π§ Think β ponder anywhere π€ With Claude β hands-on, do together
Full lesson lives in 01-request-lifecycle.html. Quick recap:
@app.get("/ping") registers the function below as the handler. Decorators are functions wrapping functions β read the body, not the name (see @development_only in routes.py, which only warns, doesn't disable).report_to_db).-> OutputResponse annotation is the contract.Add a tiny GET /countries endpoint to app.py that returns COUNTRIES_WAREHOUSES. Decide async def vs def and justify it. 5β6 lines, you write it, I review.
In TS, types vanish at compile time β they never check real runtime data. Pydantic is the opposite: models validate and coerce incoming JSON at runtime. If the body doesn't match, FastAPI returns 422 automatically β you never write the validation.
two_picking_positions_engine/models/model.py
class InputRequest(CamelModel):
order_id: int = Field(description="Order ID.")
warehouse_id: int
prioritization_time: datetime
products: List[Products]
country: Optional[str] = Field(None, ...)
interface that actually runs. Optional[str] = None β country?: string. List[Products] β Products[]. But unlike TS, sending "order_id": "abc" is rejected before your code runs.
Enums & Decimal: StorageType is an Enum (only MANUAL/AUTOMATED accepted). amount: Decimal β not float β because money/quantities need exactness.
Output is validated too: OutputResponse(products: Dict[int, str]) β a map of product id β sector. Return type -> OutputResponse shapes what leaves.
order.json() (in redis_repository.add_order_data). That method is deprecated in v2 in favour of model_dump_json() β a real "should we modernise?" question you'll meet.
Pydantic: models Β· FastAPI: request body
Add an optional field note: Optional[str] = None to InputRequest. Send a request with "note": "test" (camelCase!) and confirm it round-trips. Then make it required and watch the 422.
Why is amount a Decimal and not a float? What goes wrong with 0.1 + 0.2 in floats, and why does a picking quantity care?
There are 4 gunicorn worker processes. A Python variable in one worker is invisible to the others, and dies on restart. So shared, fast, live state lives in Redis β an in-memory key-value store outside the app.
This repo uses exactly two Redis structures per order (see redis_repository.py):
two_picking_positions_engine/repositories/redis_repository.py
# 1) HASH β full order detail, keyed by id
hash_key = f"order:{order.order_id}"
self.r.hset(hash_key, mapping={"data": order.json()})
# 2) SORTED SET β a priority queue: order_id scored by prio_time
sorted_set_key = f"warehouse:{warehouse_id}:orders"
self.r.zadd(sorted_set_key, {order_id: prioritization_time.timestamp()})
The hash answers "give me order 123's data". The sorted set answers "which orders come before this priority time?" (get_order_ids_before_prio_time β zrevrangebyscore). Score = unix timestamp, so time ordering is free.
bytes, not str β hence data[b"data"].decode("utf-8") and k.decode("utf-8") everywhere. Forget the decode and you compare b"x" to "x" and it silently fails. A classic first-week bug.
Resilience: _execute_with_retries wraps every call with reconnect + exponential backoff. Batching: get_order_data_batch uses a pipeline to fetch many orders in one round-trip (a real perf pattern).
Redis: sorted sets Β· Redis: hashes Β· Redis University
With the stack running, use the dev routes to drive Redis: POST to /process_order_without_decision, then GET /get_orders/cz/8799 and GET /get_order_data/cz/<id>. Then open redis-cli and find the same data with ZRANGE and HGETALL. Connect the Python methods to the raw Redis commands.
Why store the order detail in a hash but the ordering in a sorted set? Why not one structure? (Hint: what does each query need to be fast?)
Not every input is an HTTP request. Other systems publish events ("order PICKED", "order cancelled", "work volume recomputed") to RabbitMQ, and this service consumes them in the background to keep its Redis state current.
One consumer per country (per tenant vhost). It declares one queue bound to three exchanges (rabbit_consumer.py):
two_picking_positions_engine/consumers/rabbit_consumer.py
self.exchanges = [
{"name":"wapi-events", "type":"direct", "routing_key":"wapi-order-event"},
{"name":"web.order.event", "type":"topic", "routing_key":"web.order.event.cancel"},
{"name":"workers-balancing", "type":"topic", "routing_key":"work-volume-prediction.computed"},
]
The callback routes by method.exchange: order events β update/remove orders in Redis; balancing events β update work-volume prediction. Then it acks the message (tells Rabbit "done, delete it").
basic_ack β deliberately, to "prevent infinite reprocessing of bad messages." The cost: a failed message is silently dropped (no dead-letter queue). Is that the right call? A real design discussion you could own.
RabbitMQ: topics & routing (Python/pika) Β· AMQP concepts: exchanges, queues, bindings
Why consume order events via a queue instead of having the other system POST to /order_picked? List 2 advantages of the queue and 1 disadvantage.
Local stack runs RabbitMQ with a management UI at localhost:15672 (guest/guest). Open it, find the ...-queue, publish a fake WAPI_ORDER_EVENT with "type":"PICKED" to the wapi-events exchange, and watch the order disappear from Redis. We'll craft the JSON together.
Redis is fast but ephemeral. For a permanent record of every decision (for analytics in Metabase), the service writes rows to PostgreSQL via the psycopg2 driver. This is the one place you write raw SQL.
two_picking_positions_engine/app/utilities.py Β· push_to_db()
connection = psycopg2.connect(host=mysql_secret['host'], ...)
cursor = connection.cursor()
insert_sql = f"""INSERT INTO {schema}.{table} (...) VALUES (%s, %s, ...)"""
cursor.executemany(insert_sql, data_to_insert)
connection.commit()
commit() is what actually persists β forget it and your write vanishes. There's no ORM here; it's hand-written SQL.
The values use %s placeholders β psycopg2 escapes them safely (this is how you avoid SQL injection). The table/schema names are f-string-interpolated, which is fine only because they're hard-coded constants.
finally block closes the connection and then the cursor β wrong order, the cursor should close first. (2) report_to_db checks the env profile twice with overlapping conditions. Spotting these is exactly the skill that lets you ship safely.
Env gating: report_to_db returns early when SPRING_PROFILES_ACTIVE is development/testing β so local runs never touch the real analytics DB.
psycopg2: basic usage Β· SQLBolt (interactive SQL) Β· Why parameterise (SQL injection)
Why is %s for values safe but f-string for values dangerous? And why is the DB write on a background task rather than inline in the request? (ties back to Session 1)
We'll spin up a local Postgres, create the decision_analysis table from the docstring schema, point a dev profile at it, fire a real order with double-picking products, and watch the rows land. Then you'll write one SELECT to read them back.
This is the biggest mental shift from the browser. In production the app runs under Gunicorn with 4 UvicornWorkers β four separate OS processes, each with its own memory, its own event loop, its own Redis connection.
two_picking_positions_engine/gunicorn_conf.py
worker_class = "uvicorn.workers.UvicornWorker"
workers = 4
Three kinds of concurrency live in this repo, and mixing them up is how you create bugs:
asyncio.to_thread and a metrics monitor thread; BackgroundScheduler (APScheduler) runs update_redis hourly.background_tasks.add_task(report_to_db, ...) runs after the response is sent, on the same worker β not a separate service. A slow background task still ties up that worker. Know the difference before you lean on it.
FastAPI: async, in a hurry Β· Gunicorn: design (workers) Β· FastAPI: background tasks
A request handler does time.sleep(3) (blocking). With 4 workers, how many concurrent requests can the whole service still answer during those 3 seconds? Now make it await asyncio.sleep(3) β what changes?
We'll add a deliberately-blocking endpoint, hit it with a few parallel curls, and watch the TimingMiddleware queue_time climb in the logs. Then fix it and watch the queue drain. Feeling the event loop stall is the lesson.
The app holds almost no config itself. At startup it pulls per-tenant settings (Redis host, RabbitMQ creds, DB) from a central Spring Cloud Config Server via the config-client library β selected by the SPRING_PROFILES_ACTIVE profile.
two_picking_positions_engine/configs/app_config.py
cfg_client = RhlConfigClient(
address=os.getenv("RHL_CONFIG_SERVER", ...),
app_name=f"_all_apps,{APP_NAME_DASHES}",
profile=os.getenv("SPRING_PROFILES_ACTIVE", default="testing"),
...)
# later: get_app_config().get("app.tenants.cz.redis")
.env, config is fetched from a server at boot β like remote feature flags / runtime config. SPRING_PROFILES_ACTIVE is the "which environment am I" switch (development / testing / staging / production).
Multi-tenant: get_app_config().get(f"app.tenants.{country}") returns that country's Redis + RabbitMQ block β that's how one codebase serves at/cz/de/hu/ro. Secrets arrive encrypted and are decrypted client-side (encryption.py).
_config global). Change config on the server and a running pod won't see it until restart. Good to know when "I changed the config but nothing happened."
What breaks if the config server is unreachable at startup? (Hint: fail_fast=True.) Is failing fast the right choice for this service β why or why not?
We'll trace one value β say cz's Redis host β from SPRING_PROFILES_ACTIVE through get_app_config() to the actual RedisRepositoryCountry connection, and run app_config.py's __main__ block to print a tenant's config.
Everything converges here. To ship a change you need the loop: run β change β verify β commit β MR.
docker compose up (or poetry install + uvicorn ...app:app --reload). Hit localhost:8080/ping.TimingMiddleware).Poetry: basic usage Β· FastAPI: testing (TestClient)
Pick a real, small change and ship it end-to-end with me: e.g. add a useful dev route, add a field to OutputResponse, or fix the finally cursor-close bug from Session 5. We'll run it, write a quick test, and prep the MR. This is the mission β when you've done it once, you're unblocked.
Before any change here, what's the smallest way to convince yourself it works without deploying? (TestClient? a curl? reading logs?) Building that habit is what "senior" looks like in a new stack.
When you've worked through the theory and quizzes, message me your quiz score per session and which π€ exercise you want to start with β I'll set up the local stack and we'll do the hands-on parts together. Quiz answers are promoted into GLOSSARY.md as you nail them.