Actor exits success with errors buried in a summary dataset item — the signal that gets filtered away
An external job runner (here: Apify) can report a successful run while every application-level error it hit gets pushed into one special dataset item — and any caller that filters that item out (because it isn't a "real" business record) loses the only signal that the job actually failed.
The signal that gives it away
- Run duration is suspiciously short relative to the workload size — hundreds of items processed in seconds instead of the usual minutes.
- "0 results" looks identical whether the job legitimately found nothing or the job errored out before doing any real work — the two cases are indistinguishable downstream unless something deliberately reads the report.
- Platform-level status says SUCCEEDED / exit code 0, but the *content* of the dataset tells a different story.
- Every run's dataset contains one record with a distinguishing marker (a type field, a fixed name) that no ordinary business item has — and a naive "filter to the expected schema" step silently drops exactly that record.
How to exploit it (i.e., how to detect and fix it)
1. Have the job push ONE explicit item at the end of the run acting as a report: machine-readable status ("success" / "error"), a list of error strings, basic stats. This is a convention you own, not something the platform enforces for you — Apify's own exit code is decided purely by whether an unhandled exception propagated out of the run (the async with Actor: block succeeds with exit 0 unless something raises all the way to the top; Actor.exit() itself defaults to a successful exit code regardless of what you've pushed to the dataset). So if your job's own code catches its errors internally and still returns normally — which is usually the *right* call for a long batch, you don't want one failed sub-item killing the other 800 — the platform will still report SUCCEEDED. Nothing downstream of the platform boundary knows anything went wrong unless you told it yourself, in the data.
2. On the caller side, never fetch "all dataset items" and treat every one as a business record. Split the fetch: extract the report item first, then treat the rest as data. Add an explicit opt-in mode to the shared fetch helper (e.g. a return_summary=True kwarg that changes the return shape to (items, summary)) rather than changing the default — old call sites keep working, new call sites opt in deliberately. The platform itself ships an analogous filter, but it's worth knowing exactly what it is before reaching for it: Apify's skipFailedPages dataset-fetch option explicitly drops any item carrying an errorInfo property, while simplified gets a similar-looking result through a different mechanism — it's shorthand for applying fields=url,pageFunctionResult,errorInfo plus unwind=pageFunctionResult, so error items tend to fall out as a side effect of the field selection and unwind rather than being filtered on purpose. Both are legacy API v1 emulation kept only for backward compatibility with the old Apify Crawler product, and the docs are explicit that neither is "recommended to use in new integrations." The lesson still generalizes: any filter whose job is "hide the noise" is also perfectly capable of hiding the only evidence that something failed, whether that filter is one you wrote yourself or a legacy platform option you opted into.
3. Aggregate errors across every sub-batch of a run — a large job is usually split into many actor invocations, not one — into a single list, and classify the error kind with a simple keyword match on the joined text (e.g. "token" + "expired" + "401" → an auth-related failure kind, anything else → a generic "external-job error"). This classification is what lets the surface facing a human show a specific, actionable message instead of a generic "something went wrong, try again."
4. Treat "the report says error" and "zero business items came back" as two INDEPENDENT boolean signals feeding any cleanup/merge logic downstream — never collapse them into one. A destructive cleanup step (e.g. "remove all records that weren't refreshed this round because they must be stale") must refuse to run — return 0 removed, log a warning — if EITHER signal is set, not only when it sees literally nothing at all.
5. Where feasible, add a cheap preflight check before the expensive job runs (e.g., a short HTTP call to confirm a credential the job depends on is still valid) so the most common failure mode never reaches the paid job in the first place. This doesn't replace step 4 — a credential can still be revoked mid-run — but it converts most failures into "zero cost, immediate, specific message" instead of "wasted a full paid run for nothing."
6. Diagnostic checklist when you suspect this pattern is live: run duration far shorter than typical for that batch size; "0 processed"/"0 enriched" co-occurring with a non-empty original input set; the platform's own run log containing the word "error" or "failed" despite the run status showing green.
What doesn't work
- Trusting the platform's run status as the only success signal. Green/SUCCEEDED only means "no unhandled exception reached the top level" — it says nothing about whether the job's own logic considers its purpose accomplished. Any job that catches its own errors internally (usually good practice — one bad sub-item shouldn't kill the whole batch) needs a second, independent success signal that survives past the platform boundary, because the platform's own signal won't carry it. - A blanket `except Exception` around the whole job that swallows and logs. It keeps the run green for the platform, but if nothing downstream ever reads that log, the error has vanished from every surface a human or an agent actually looks at — dashboard, database row, chat message. - Filtering the dataset by "is this a valid business record" without first checking for a report item. A filter of the shape "keep everything except the one item with the special marker" is silently correct nearly all the time and silently catastrophic the rest of the time — because it throws the report away along with the noise it was built to remove. - Treating "0 valid results returned" and "0 results available" as the same fact. They call for completely different UX and completely different downstream action — retry-and-alert versus "nothing to show, that's expected." Conflating them is exactly what lets a downstream cleanup step mistake an error for a legitimately empty result and delete data that was never actually invalidated. - A single combined boolean ("did anything go wrong?") instead of two separate signals (error-detected, zero-results). A cleanup guard that only checks "were there zero results this round" will also fire on the rare case where zero really is the correct, error-free answer — you need the explicit error signal as its own gate, not folded into the zero-check.
Related
- A per-call timeout doesn't kill the underlying thread — the process outlives its own deadline and blocks actor exit — same actor-runner family, a different way an external job's reported lifecycle (this time an outright hang) lies about what actually happened inside it. - A catch-all except wrapped around a webhook handler swallows the 401 it deliberately raised, and answers 200 — the same shape of bug one layer down the stack: a broad catch turns a deliberate failure signal into a false "everything's fine." - Coarse boolean completion gates hide partial completion — another case of one coarse boolean hiding a more nuanced true/false/partial state that downstream logic actually needs. - No long-running agent framework handles failure for you — you need an explicit error contract — the general principle this is one instance of: no framework or platform gives you a failure contract for free for anything long-running or external, you build one explicitly. - Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — a sibling failure mode in the same family: the error happened, but nothing downstream is actually capable of seeing it.
Verified against
3 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.