What you'll learn: how to answer four Python and API questions (validating input at the boundary, choosing between async and threads, turning failures and timeouts into useful responses, and testing a model-backed service without a model) with a structure you can reuse, code that runs, and a rubric to score yourself.
Backend engineers who move from Java or Node to Python get tested on whether their habits transfer. The interviewer wants to hear what you would validate at the boundary, what blocks what, and what you would test.
The examples use the course capstone, a small FastAPI service for the fictional Lumen Industrial Supply. Its default model provider is a rule-based mock, not a model, and its Anthropic adapter has never been run against the live API. The lesson The interview loop and an answer framework explains how a round runs.
Figure 1. Where each question sits on the request path
How to read this: the straight line is the happy path, and every branch ends in the same error body; Q4 is about proving each branch with tests.
How to use this page
Say your answer aloud for four minutes before you read the example. Every code excerpt is copied from a file in capstone/fde-product-discovery-copilot and checked against it. For the reasoning behind Q2 and Q3, see reliability and scaling; for Q4, see evaluation.
Questions
Q1. A customer's system posts JSON to your API with a quantity field. It is sometimes 40, sometimes "40", sometimes -3. Where do you validate it, and what happens to a bad value?
What it assesses. Whether you know that a Python type hint is not a runtime check, whether you choose between coercing and rejecting on purpose, and whether you treat the model's output as input that needs the same checks as the customer's.
Answer framework.
- Say where: at the boundary, with a typed model, before any business code runs.
- Say what the schema encodes: type, range, length and pattern.
- Decide per field whether to coerce or reject, and give the reason.
- Describe the error: a status code, the field name, never the submitted value.
- Apply the same model to what the LLM returns.
- Map it to the Java side you already know.
Strong answer example. The spoken answer comes in three parts, each followed by the code behind it.
The Python runtime does not enforce type hints, so a hint alone protects nothing. I would put a Pydantic model at the boundary: quantity an integer above 0 and at most 10,000, the SKU matching a pattern, the customer reference with a length limit. The framework runs that check before my handler sees the body.
Verified: capstone/fde-product-discovery-copilot/app/workflow/approval.py
class QuoteDraftArgs(BaseModel):
sku: str = Field(pattern=r"^LUM-[A-Z]{2}-\d{3}quot;)
quantity: int = Field(gt=0, le=10_000)
customer_ref: str = Field(min_length=1, max_length=60)
Pydantic coerces by default (its documentation calls this lax mode). So "40" and 40.0 become the integer 40, while "40.5" and -3 are rejected with an error that names the field. For a quantity I accept that, because I do not control the caller's serialiser. For an identifier or an amount of money I would use strict mode, where "40" is refused, so a bug on the caller's side is not hidden.
Verified: capstone/fde-product-discovery-copilot/lessons/REF-python-api/test_ref_python_api_boundaries.py
@pytest.mark.parametrize("sent", [40, "40", 40.0])
def test_lax_mode_coerces_values_that_are_safely_integers(sent: object) -> None:
assert _quote_args(sent).quantity == 40
@pytest.mark.parametrize("sent,kind", [("40.5", "int_parsing"), (-3, "greater_than"), (10001, "less_than_equal")])
def test_bad_values_are_rejected_and_the_error_names_the_field(sent: object, kind: str) -> None:
with pytest.raises(ValidationError) as err:
_quote_args(sent)
first = err.value.errors()[0]
assert first["loc"] == ("quantity",) and first["type"] == kind
In the capstone this model guards the approval gate, and
DiscoveryRequestguards the HTTP body the same way. The error returns as 422 with field names and never the submitted value, which may be private text. Pydantic models also validate what the LLM returns (complete_structured, with one repair attempt), because model output is untrusted too. In Spring the closest match is a class with Bean Validation annotations, where constraints are declared on properties and enforced by a validator; Pydantic also parses and coerces the input in the same step.
Verified: capstone/fde-product-discovery-copilot/app/errors.py
@app.exception_handler(RequestValidationError)
async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
# Say where the problem is, never echo the submitted value (it may hold private text).
fields = ", ".join(".".join(str(p) for p in e["loc"][1:]) or "body" for e in exc.errors())
return JSONResponse(error_body("validation_error", f"Invalid input: {fields}", _trace(request)), 422)
Common mistakes.
- Trusting type hints at runtime; they are documentation for tools until a library checks them.
- Validating deep inside business code, so a bad value fails late with a stack trace.
- Coercing every field, including identifiers and money.
- Echoing or logging the rejected value.
- Validating the request but not the model's JSON.
Follow-up questions.
- When would you switch a field to strict mode, and what breaks for existing callers?
- A field is optional in version 1 and required in version 2. How do you roll that out?
- The model returns valid JSON with a plausible but wrong value. What catches it?
Scoring rubric.
| Criterion | Weak | Acceptable | Strong |
|---|---|---|---|
| Where | Checks inside the handler | Uses a model at the edge | Edge model plus a second check on model output |
| Coerce or reject | Never considered | Knows Pydantic coerces | Chooses per field and names the failure each choice prevents |
| Error design | Raw exception or 500 | 4xx with a message | One body shape, field names only, no echoed value |
| Transfer | No comparison | Names the Java equivalent | Says what is the same and what differs |
Q2. Your endpoint calls a model provider that takes 3 to 8 seconds. Should the handler be async def or def, and what goes wrong if you choose badly?
What it assesses. Whether you understand what blocks what. The interviewer checks that you can tell I/O waiting from CPU work, do capacity arithmetic, and translate Spring's thread-per-request model into FastAPI's two options.
Answer framework.
- Ask what the handler waits on, and whether the client library has an awaitable version.
- A blocking client means
def: the framework runs it in a thread pool. A fully awaitable chain meansasync def. - Name the failure of the wrong choice: a blocking call inside
async defstalls every request on that event loop. - Do the capacity arithmetic: worker threads divided by latency.
- Say what you would measure and which lever you pull first.
Strong answer example. The answer starts from the client library, not from fashion.
It depends on the client. The reference service uses a synchronous client, so its handlers are plain
def. FastAPI's documentation says adefpath operation runs in an external thread pool and anasync defone runs on the event loop. A blocking call insideasync deftherefore freezes every other request on that loop, while the same call insidedefoccupies one thread. I would move toasync defonly if the whole chain, down to the HTTP client, can be awaited.
Verified: capstone/fde-product-discovery-copilot/lessons/REF-python-api/test_ref_python_api_boundaries.py
def test_def_routes_run_in_a_worker_thread_and_async_def_routes_on_the_event_loop() -> None:
app = FastAPI()
seen: dict[str, int] = {}
@app.get("/sync")
def sync_route() -> dict[str, bool]:
seen["sync"] = threading.get_ident()
return {"ok": True}
@app.get("/async")
async def async_route() -> dict[str, bool]:
seen["async"] = threading.get_ident()
return {"ok": True}
with TestClient(app) as client:
client.get("/sync")
client.get("/async")
assert seen["sync"] != seen["async"]
Capacity is a division. Starlette runs
defendpoints through AnyIO, whose documented default is 40 worker threads. At 6 seconds a call, 40 / 6 = 6.7 requests a second at most; at 10 a second, requests queue and everyone slows down. Waiting on a network call costs memory, not CPU, so I would first add a concurrency cap that returns a clear 503, and rewrite for async only if measurements justify it.
Illustrative numbers: the 6 second latency and the 10 requests a second are invented. The limit of 40 is the library default as documented on 2026-11-02; check the limit your versions use.
| Spring Boot (MVC) | FastAPI |
|---|---|
| A servlet container with a large thread pool absorbs blocking calls | A def handler runs in a thread pool; an async def handler runs on one event loop |
In WebFlux, blocking calls do not belong on the non-blocking threads; Reactor's publishOn moves them to another thread |
The same rule: never call blocking code from async def |
Common mistakes.
- Writing
async defeverywhere and calling a blocking client inside it. - Believing threads are always slow. For I/O waits they are fine; for CPU-bound work in the default CPython build the GIL limits them.
- Skipping the arithmetic, so the first load test is the first time anyone sees the limit.
- Raising the thread limit with no memory check and no cap on queued requests.
Follow-up questions.
- The provider adds streaming. What changes?
- One endpoint does 200 ms of CPU work per call. Where does it run, and does async help?
- What does
asyncio.to_threaddo, and when would you reach for it?
Scoring rubric.
| Criterion | Weak | Acceptable | Strong |
|---|---|---|---|
| Mechanism | "Async is faster" | Knows def uses a pool |
Explains the loop, the pool and what blocking does to each |
| Choice | One style for everything | Picks by client library | Picks by client library and states when it would switch |
| Arithmetic | None | Mentions concurrency | Computes threads over latency and the queue effect |
| Levers | Add servers | Raises the thread limit | Cap and 503 first, then pool size, then async, each justified |
Q3. The model provider hangs, or throws. What does your API return, and how do you stop one slow call from holding the whole request?
What it assesses. Whether you map failures to honest status codes with one error shape, and know the difference between a limit you check and a limit you can enforce. It rewards a candidate who tests the gaps in their own service.
Answer framework.
- Classify the failure: the caller's fault (4xx), the upstream's (502, 503, 504) or your bug (500).
- Return one error body with a code and a trace id, never a stack trace.
- Put a timeout on every network call, plus a deadline for the whole request.
- Check that each limit can interrupt work, not only be noticed afterwards.
- Tell the caller what is safe to do next.
Strong answer example. Start by reading back the error shape the service already has.
| Code | Status | Raised when |
|---|---|---|
unauthorized |
401 | Token missing, malformed, expired or wrongly signed |
forbidden |
403 | The role may not perform the action |
idempotency_conflict |
409 | The same key with a different body |
validation_error |
422 | Input fails the schema |
llm_invalid_output |
502 | Model JSON still invalid after one repair |
internal_error |
500 | Anything else, with a generic message |
That table is a subset of the capstone's own codes, and its tests show two gaps. First, a provider that raises a connection error is not a failure the code names, so it lands in the last row: a generic 500, safe but misleading, because the caller cannot tell a bug from an outage.
Verified: capstone/fde-product-discovery-copilot/app/errors.py
@app.exception_handler(Exception)
async def _unexpected(request: Request, exc: Exception) -> JSONResponse:
log.error("unhandled error: %s", type(exc).__name__)
return JSONResponse(error_body("internal_error", "Something went wrong.", _trace(request)), 500)
Second, the workflow limit. The runner compares the clock to its limit at the top of each step, so it can stop a slow loop but never a slow call.
Verified: capstone/fde-product-discovery-copilot/app/workflow/runner.py
for step in range(1, self.max_steps + 1):
if self.clock() - started > self.timeout_s:
result.status = "timeout"
return result
In the test below a fake clock moves 30 seconds during one planning call against a 5 second limit, and nothing notices until the call returns.
Verified: capstone/fde-product-discovery-copilot/lessons/REF-python-api/test_ref_python_api_boundaries.py
def test_the_time_limit_is_checked_between_steps_so_one_slow_call_overshoots_it() -> None:
clock = FakeClock()
ctx = ToolContext(SERVICES.catalog, frozenset({"public"}))
runner = WorkflowRunner(SlowPlanner(clock), default_registry(), ctx, timeout_s=5, clock=clock)
result = runner.run(Requirements())
assert result.status == "timeout" and len(result.steps) == 1
assert clock.now == 30 # a 5 second limit, and the first call ran for 30 before anything noticed
The fix has two parts. Set explicit timeouts on every client: httpx lists connect, read, write and pool timeouts, with a default of 5 seconds of network inactivity. Then add a request deadline:
asyncio.timeoutin async code, or in a plaindefhandler a deadline passed down so each call gets the time that remains. Map a timeout to 504 and an unreachable provider to 502 or 503, with a code the caller can act on.
AnthropicProvider passes no timeout or retry setting, so it inherits the Anthropic SDK's documented defaults (as of 2026-11-02): a 10 minute timeout and 2 automatic retries. It has not been run live, so setting both is a task for your own key.
Common mistakes.
- Mapping every exception to 500, so the caller cannot tell an outage from a bug.
- Putting a timeout on the loop and none on the call inside it.
- Returning the exception text, which can contain a URL or private input.
- Using one deadline per call and none per request, so five slow calls add up to minutes.
Follow-up questions.
- Which of these failures should the caller retry, and which never?
- How do you pass the remaining time budget from the request down to each model call?
- How would you test a timeout without waiting for one?
Scoring rubric.
| Criterion | Weak | Acceptable | Strong |
|---|---|---|---|
| Classification | All errors are 500 | Separates 4xx from 5xx | Separates caller, upstream and own bugs, with a code for each |
| Error body | Free text or stack trace | Consistent JSON | One shape, trace id, no internal detail |
| Timeouts | One global number | A timeout per call | Per-call timeouts plus a request deadline that can interrupt |
| Testing | Hopes it works | Mocks an exception | Injects a fake clock and a failing provider and asserts the body |
Q4. You know Spring Boot. How would you test this FastAPI service without calling a real model, and what differs from what you are used to?
What it assesses. Whether you can design tests around a dependency that is slow, costly and non-deterministic: find the seam, inject failures on purpose, keep tests separate from evaluation, and say what the tests do not prove.
Answer framework.
- Find the seam: the one interface through which the code talks to the model.
- Replace it with a deterministic double, and with doubles that fail in chosen ways.
- Test through the HTTP layer with the framework's client, and test pure logic directly.
- Inject failures: invalid JSON, a looping plan, a crashing tool, a provider error.
- Keep quality measurement (a golden set) separate from behaviour tests.
- Finish with what the tests do not prove.
Strong answer example. The seam is one method, complete, on the LLMProvider protocol. A double that returns scripted replies and records its calls is about a dozen lines.
Verified: capstone/fde-product-discovery-copilot/tests/helpers.py
class ScriptedProvider:
"""Returns the given texts in order, whatever the prompt. Records every call."""
name = "scripted"
def __init__(self, texts: list[str]) -> None:
self.texts = list(texts)
self.calls: list[list[Message]] = []
def complete(self, messages: Sequence[Message], schema: dict[str, Any] | None = None) -> Completion:
self.calls.append(list(messages))
return Completion(self.texts.pop(0), 10, 5)
With that double, a looping model becomes a test, not a hope: the model asks for a search ten times, the runner is limited to three steps, and the test asserts both the status and the number of model calls.
Verified: capstone/fde-product-discovery-copilot/tests/test_workflow.py
def _looping_provider(n: int) -> ScriptedProvider:
step = {"action": "tool", "tool": "search_catalog", "args": {"query": "pump"}}
return ScriptedProvider([json_text({**step, "args": {"query": f"pump{i}"}}) for i in range(n)])
def test_runner_stops_at_max_steps(ctx: ToolContext) -> None:
provider = _looping_provider(10)
result = WorkflowRunner(provider, default_registry(), ctx, max_steps=3).run(Requirements())
assert result.status == "max_steps" and len(result.steps) == 3 and len(provider.calls) == 3
The HTTP layer uses FastAPI's
TestClient, which needs httpx and is called like an httpx client from plaindeftests. The fixture builds the app with the doubles passed in.
Verified: capstone/fde-product-discovery-copilot/tests/conftest.py
@pytest.fixture()
def client(settings: Settings, services: Services) -> Iterator[TestClient]:
app = create_app(settings, services)
with TestClient(app, raise_server_exceptions=False) as c:
yield c
| Spring Boot | This service |
|---|---|
@WebMvcTest with MockMvc queries controllers without a server |
TestClient(app) calls the app in process from ordinary functions |
@MockitoBean replaces a bean in the context |
Pass a different object into create_app(settings, services); app.dependency_overrides is the built-in route for Depends |
What the tests do not prove matters as much. The mock is a rule-based stand-in, so passing tests show the plumbing handles the cases I scripted, not that a real model behaves. The Anthropic adapter has no live test. The evaluation numbers come from a golden set its author tuned against, so they are a regression check, not an accuracy estimate.
Common mistakes.
- Calling the real model in unit tests: slow, costly and different on every run.
- Mocking so deeply that the test only checks the mock.
- Asserting on exact model wording instead of structure and rules.
- Testing the happy path only, so loops, bad JSON and tool crashes are found in production.
Follow-up questions.
- How would you test the real provider adapter safely, and where would that test run?
- Which tests would you add before the service touches customer data?
- How do you test code that depends on the current time?
Scoring rubric.
| Criterion | Weak | Acceptable | Strong |
|---|---|---|---|
| Seam | Patches internals | Mocks the provider | Identifies one interface and injects it at construction |
| Failure cases | Happy path only | A few error cases | Scripts invalid JSON, loops, crashes and outages, each asserted |
| Separation | Mixes quality with behaviour | Mentions evaluation | Keeps tests deterministic and runs evaluation separately |
| Honesty | Overclaims | Notes the mock | States what is unproven: live adapter, real model, in-sample scores |
Last verified: 2026-11-02
How to practise
- Run
./.venv/Scripts/python -m pytest lessons/REF-python-api -qin the capstone folder, change one assertion and read the failure message. - Redo the Q2 arithmetic with your own latency and thread limit.
- Add a provider that raises
TimeoutErrorand write the test that says what the caller should see. - Explain
defversusasync defto a friend who writes Spring, using the table. - Score yourself with each rubric and repeat the criterion you scored lowest.
Next
Continue to Interview questions: RAG and agentic workflows, which covers chunking, retrieval failures, access control, evaluation, bounded workflows and when not to use an agent.