Python Backend Β· Full Exercise Workbook

From frontend to shipping backend

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.

🎯 Mission: independently ship a change to this service. Eight sessions, each ending in a concrete skill you'll need to do that. Sessions build on each other β€” go top to bottom.

πŸ“– Quiz β€” tap, instant feedback (great on phone) 🧠 Think β€” ponder anywhere 🀝 With Claude β€” hands-on, do together

Quizzes: 0 / 0 answered correctly
1

Request lifecycle

routing Β· async Β· JSON β€” the map of every change
β–Ά

Full lesson lives in 01-request-lifecycle.html. Quick recap:

  • Decorator = router. @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).
  • async runs on the event loop (same as JS). Blocking work there stalls every request on the worker β†’ push it to a background task (like report_to_db).
  • Return value = response body. Return a dict, get JSON. The -> OutputResponse annotation is the contract.
  • CamelModel bridges camelCase JSON ⇄ snake_case Python.
🀝 Build A

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.

2

Pydantic models & validation

the request/response contracts β€” your most-pulled lever
β–Ά

Theory

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, ...)
πŸŒ‰ From your world: it's a TS 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.

⚠️ Pydantic v2: the repo uses 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

🀝 Build B

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.

🧠 Think

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?

3

Redis β€” the cache & shared state

why a backend needs memory outside the process
β–Ά

Theory

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.

πŸŒ‰ From your world: think of Redis as a Zustand/Redux store that lives on the server, shared by every worker β€” but you talk to it over the network, and it only speaks bytes and a handful of data types.

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.

⚠️ Redis speaks bytes. Reads come back as 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

🀝 Build C

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.

🧠 Think

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?)

4

RabbitMQ β€” messaging

how the service stays in sync without being called
β–Ά

Theory

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.

πŸŒ‰ From your world: it's an event bus / pub-sub, like listening to events you didn't trigger. Publisher and consumer never call each other directly β€” they're decoupled through a queue. If the consumer is down, messages wait.

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").

⚠️ Poison-message tradeoff: on any exception, the code still calls 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

🧠 Think

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.

🀝 Build D

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.

5

Postgres β€” the database

durable storage & reading SQL critically
β–Ά

Theory

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()
πŸŒ‰ From your world: a connection is like a fetch session to the DB; a cursor is the thing that runs a statement and iterates results. 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.

⚠️ Two real bugs to find here (great reading practice): (1) the 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)

🧠 Think

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)

🀝 Build E

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.

6

Concurrency & runtime

workers vs threads vs async β€” why latency behaves the way it does
β–Ά

Theory

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:

  • Processes (gunicorn workers) β€” true parallelism, no shared memory β†’ that's why state is in Redis.
  • async / event loop β€” one thread, cooperative; great for I/O waiting, deadly if you block it.
  • Threads β€” the repo runs the RabbitMQ consumer via asyncio.to_thread and a metrics monitor thread; BackgroundScheduler (APScheduler) runs update_redis hourly.
πŸŒ‰ From your world: the browser gives you one event loop and Web Workers. Here you also have multiple processes (like running your app 4Γ— behind a load balancer) and real OS threads. "Who can see this variable?" stops being obvious.

⚠️ BackgroundTasks vs background threads: 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

🧠 Think

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?

🀝 Build F

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.

7

Config & secrets

how the same code serves 5 countries
β–Ά

Theory

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")
πŸŒ‰ From your world: instead of a local .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 is fetched once, lazily, and cached (the module-level _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."

🧠 Think

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?

🀝 Build G

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.

8

Capstone β€” run locally & ship an MR

the mission: a change that goes out the door
β–Ά

Theory

Everything converges here. To ship a change you need the loop: run β†’ change β†’ verify β†’ commit β†’ MR.

  • Run: docker compose up (or poetry install + uvicorn ...app:app --reload). Hit localhost:8080/ping.
  • Verify: curl your endpoint; use the dev routes; read the logs (TimingMiddleware).
  • Ship: branch, commit (the repo's style is terse, lowercase, scoped), push, open a GitLab MR. CI runs Package β†’ Tag β†’ Deploy + an AI review. CODEOWNERS routes review to team-opex-outbound.
πŸŒ‰ From your world: same git/PR muscle you already have β€” the new parts are the Python toolchain (Poetry, not npm) and the GitLab/ArgoCD pipeline (not Vercel). The deploy is config-as-code; you ship the image, ArgoCD rolls it out.

Poetry: basic usage Β· FastAPI: testing (TestClient)

🀝 Capstone

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.

🧠 Think

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.