A metrics scraper's generator function validates its config, but the error only appears once the consumer iterates. Why, and how do you make it raise at call time?
answer
- The failure lands on the wrong side
- Nothing in the body has run yet
- One keyword makes the whole body lazy
- The eager part needs its own function
- Public wrapper returns a private generator
basics
~20 sBecause no statement of a generator function's body runs until the first next(), the validation is deferred to the consumer. Split the function: a plain function validates eagerly and returns the generator built by a private generator function.
solid answer
~50 sThe body of a generator function does not execute at call time — the call only builds a generator object — so a `raise` at the top of the body fires on the first `next()`, inside whoever iterates. That misplaces both the traceback and the failure boundary: the scraper reports a bad endpoint from deep inside an export loop rather than at wiring time. The standard fix is the **eager wrapper**: make the public function an ordinary function that validates its arguments, then `return` the generator produced by a private generator function. The public call now raises immediately, while the iteration stays lazy. The same reasoning applies to any read at the top of the body: a cached counter sampled on line one is sampled when consumption starts, not when the generator was built, which is how an exporter publishes a stale cached value at a 1,200-request-per-minute peak.
code
python · 14 linesdef scrape(config):
if "endpoint" not in config:
raise ValueError("missing endpoint")
return _scrape(config)
def _scrape(config):
for i in range(2):
yield config["endpoint"], i
try:
scrape({})
except ValueError as exc:
print("raised at call time:", exc)
print(list(scrape({"endpoint": "/metrics"})))go deeper
Recall the core fact this rests on: calling a generator function runs no code, so anything written at the top of its body waits until something iterates it.
Explain why relocating the check within the body cannot help, since the compiler makes the entire body lazy, and write the two-function split with the public wrapper returning a private generator.
Diagnose from the symptom — a traceback rooted in a consumer, or a value that is stale in a way that tracks first consumption rather than wall clock — and restore the eager failure boundary without losing streaming, including a test that pins the split.
Own where laziness is allowed to cross an API boundary at all. Deferred validation and late resource acquisition change who owns a failure; set the convention for which entry points may return lazy iterators and what they must check first.
### The symptom A scraper builds its generator during setup and iterates it later, per export: ```python def scrape(config): if "endpoint" not in config: raise ValueError("missing endpoint") snapshot = read_cached_counter() # sampled... when? for name in config["endpoint"]: yield name, snapshot ``` With a bad config, nothing happens at `scrape(cfg)`. The `ValueError` arrives later, from inside the export loop, with a traceback whose top frame is the consumer. Operators read that as "the exporter is broken" rather than "the config is wrong". ### The cause Calling a generator function executes **none** of its body. It binds arguments, creates an unstarted frame and returns a generator object. The first `next()` — usually issued by a `for` loop far from the call site — runs the body from the top. So every guard clause, every log line and every read of external state at the top of the body is deferred to whenever consumption begins, and never happens at all if the generator is dropped unconsumed. Two distinct defects fall out of this: 1. **Validation lands in the wrong place.** The failure is attributed to the consumer, not the caller who supplied the bad argument, and it can fire long after the offending call. 2. **Reads are timestamped late.** `snapshot = read_cached_counter()` looks like it samples at wiring time; it samples at first-`next()` time. If the generator is built once at startup and iterated on each scrape, that snapshot is taken at the first scrape and — because the local survives suspension — reused for every subsequent yield of that generator. At a 1,200-request-per-minute peak, the exporter publishes a stale cached value with a confident-looking timestamp. ### The eager wrapper Split the function in two. The public entry point is an ordinary function — no `yield` in its body, so it is not a generator function — that validates and then returns the generator built by a private generator function: ```python def scrape(config): if "endpoint" not in config: raise ValueError("missing endpoint") return _scrape(config) def _scrape(config): for name in config["endpoint"]: yield name, read_cached_counter() ``` Now `scrape({})` raises at the call, with the caller's frame on top of the traceback, while iteration stays lazy. The rule to remember: **a function that both validates eagerly and yields lazily cannot exist** — one `yield` anywhere makes the whole body lazy, so the eager part has to live in a separate function. ### Getting the sampling right too Moving the read inside the loop (as above) samples per item. If you want it sampled once *at call time*, do it in the wrapper and pass the value in: `return _scrape(config, read_cached_counter())`. The choice is now explicit and visible, which is the real win — the original code chose "at first consumption" by accident. ### Related shapes of the same bug * **Resources opened in the body.** `f = open(path)` at the top of a generator function does not open the file at call time. A caller that builds many generators and iterates none opens nothing; a caller that builds them all and iterates later opens them all at once, potentially long after the path stopped existing. * **Logging that never fires.** "Starting scrape" logged at the top of a generator body does not appear until something iterates, so quiet periods look like the code never ran. * **Empty-input shortcuts.** A consumer that skips iteration when it already knows the result is empty silently skips the whole body, guard clauses included. ### Verifying it The cheap check in review is structural: does this `def` contain `yield`? If yes, treat everything in it as "runs on first `next()`", including the first line. At runtime, `inspect.getgeneratorstate()` returning `GEN_CREATED` proves the body has not started, and `inspect.isgeneratorfunction()` on the public entry point should be **False** once the wrapper split is in place — a neat regression test for the pattern: ```python assert not inspect.isgeneratorfunction(scrape) ``` That one assertion stops someone from later inlining `_scrape` back into `scrape` and quietly restoring the deferred-validation bug. ### The judgement being tested The interviewer is checking whether you know that laziness is a property of the *whole function body*, and whether you can restore an eager failure boundary without giving up streaming. The wrapper split is the canonical answer, and knowing why it works — the compiler decides generator-ness from the presence of `yield`, so the eager code must live in a different function — is what separates a recalled trick from understanding.
- Why can you not just move the validation above the first yield to make it eager?Because generator-ness is a property of the function, not of the position of the `yield`. The compiler flags the whole code object as a generator when it sees `yield` anywhere in the body, so line one is as deferred as line fifty. The only way to run code at call time is to put it in a function that contains no `yield`, which is exactly what the wrapper split does.
- How would you decide whether a snapshot should be read in the wrapper or inside the generator body?Ask what the value should be timestamped to. Reading it in the wrapper samples once, at call time, and every yielded item carries that one reading. Reading it inside the loop samples per item, at consumption time. Reading it at the top of the generator body gives you the worst of both: sampled once, but at whatever moment the first consumer happened to start. Make the choice explicit in code.
- What breaks if a caller builds these generators and never iterates them?Nothing in the body runs at all — no logging, no resource acquisition, no guard clauses — so the work simply does not happen and there is no error to notice. With the wrapper split, at least argument validation still fires on the call. Anything with a real side effect belongs outside the generator body for the same reason.
saying these in an interview costs you the question
- Says moving the check above the first yield fixes it
- Blames the consumer instead of the deferred body
- Wraps the body in try/except and re-raises
- Primes with a next() call inside the builder
- Cannot explain when the top-of-body read happens
- Thinks the generator object caches the exception