We caught this one an hour before publishing, and it is the kind of bug that would have looked like bad luck for months.
The Actor charged the customer's start fee, then validated their input. If the input was invalid, the run exited non-zero — after the charge. The customer paid for a run that was never going to scrape anything, and the platform recorded a FAILED run against the listing's public success rate.
Quick answer
In a pay-per-event Actor, validate before you charge. Any charge that happens before input validation bills the customer for work that cannot happen, and the resulting FAILED status is counted against your listing's public success rate — so one bad input costs you money you did not earn and the reputation you did not spend. The fix is reordering two lines.
The shape of the bug
Here is the original entry point, lightly trimmed:
async def main() -> None:
async with Actor:
await _charge(EVENT_ACTOR_START, count=1) # <-- money changes hands
cfg = await _load_config() # <-- may raise SystemExit(1)
...
And _load_config:
async def _load_config() -> ActorInput:
raw_input = await Actor.get_input() or {}
try:
return ActorInput.model_validate(raw_input)
except ValidationError as exc:
await Actor.set_status_message(f"Invalid input: {exc}")
raise SystemExit(EXIT_FAILURE) from exc
Both functions are correct in isolation. The validation is strict, the error message is useful, the exit code is right. The defect is purely the order of two statements, which is exactly why it survives code review — you read "charge for starting, then load the config" and it sounds like a description of a working program.
The fix:
async def main() -> None:
async with Actor:
# Validate BEFORE charging: an invalid input must exit non-zero without
# billing the customer for a run that was never going to scrape anything.
cfg = await _load_config()
await _charge(EVENT_ACTOR_START, count=1)
Why this is worth a gate, not a fix
We had shipped this defect before. Two other Actors in our fleet — a Reddit scraper and an agency-leads scraper — carried the same ordering, and in one of them it was genuinely hard to see: validation lived inside an async generator, so it did not execute until the first __anext__() call, long after main() had charged. The source read validate, then scrape. The behaviour was charge, then validate.
A census across our Actors found 70 of 159 charging before validating. That is not a bug you fix; it is a habit you have to stop.
So it is a check now, run in the pre-commit hook and as a publish gate, and pinned per Actor by a test that reads the source rather than trusting a comment:
def test_input_is_validated_before_actor_start_is_charged() -> None:
src = inspect.getsource(main.main)
assert src.index("_load_config()") < src.index("_charge(EVENT_ACTOR_START")
Structural assertions like this are ugly and they earn their place: the property being protected really is "these two statements appear in this order," and no behavioural test catches it without a live billing integration.
The other rule this Actor encodes: pin the country
Funda is a single-market site. Dutch listings, Dutch language, Dutch inventory. A residential proxy with a geo-random exit will still return HTTP 200 — just with the wrong locale, an interstitial, or a redirect — and nothing raises.
PROXY_COUNTRY_CODE = "NL" # REQ-10 — hardcoded, never read from cfg/user input
The comment is the important half. It is not merely a default; the value is deliberately unreachable from user input, because a country field on a single-market Actor is only ever a way for a caller to break it.
And: zero rows is not a success
If a run emits nothing, the Actor dumps the last-fetched search page's raw HTML to the key-value store and then fails loud:
if emitted == 0:
await _dump_empty_search_debug(debug_sink)
Actor.log.error("funda: zero rows across the whole run — failing loud.")
raise SystemExit(EXIT_FAILURE)
An empty dataset with a green status is the worst possible output: it looks like an answer. Persisting the bytes we were looking at when we gave up turns "were we blocked or was the parser wrong?" from a guessing session into one look at an artefact — and those two possibilities need opposite fixes.
Funda Netherlands Real Estate Scraper — Dutch sale and rent listings with price, living area, energy label, rooms and photos, priced per result.
Built by Devil Scrapes. We publish the traps we hit, including the ones that were our own fault.
Top comments (0)