# Changelog of Workday Jobs Scraper (`devilscrapes/workday-jobs-scraper`) Actor

- **URL**: https://apify.com/devilscrapes/workday-jobs-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/devilscrapes/workday-jobs-scraper.md

## Changelog

### 0.6 — 2026-08-25

#### Fixed

- **The 0.4 (2026-08-13) hard actor-wide deadline never covered the actor-start PPE charge or
  the proxy bootstrap — both real, unbounded Apify-platform network calls that ran *before*
  the deadline wrapper even existed.** `publicActorRunStats30Days` on the deployed 0.5.2 build
  reads `{TIMED-OUT: 19, SUCCEEDED: 12, FAILED: 0, TOTAL: 31}` — 38.7% customer failure, same
  shape as every prior snapshot (zero FAILED, every non-success TIMED-OUT). Re-audited `main()`'s
  call order: `_charge(EVENT_ACTOR_START, ...)` and `_build_proxy_factory(...)` (which calls
  `Actor.create_proxy_configuration()` and awaits its own first `new_url()`) both ran, then
  `client = WorkdayClient(...)` and `budget = RunBudget(...)` were constructed, and only *then*
  did `_scrape_with_hard_deadline` wrap anything — so a hang in the charge or the proxy
  bootstrap raced straight to the platform's own ~3600s default timeout, unbounded by anything
  of ours, exactly the pattern the 08-13 fix was meant to close but didn't reach. This gap was
  originally found and fixed on the (stale, unmerged) branch
  `fix/workday-jobs-hard-deadline-coverage-gap` on 2026-08-20; this entry re-applies that fix
  cleanly onto current `main` (that branch's base predated 5 days / dozens of unrelated commits)
  and drops an out-of-scope PPE re-pricing change ($0.20 → $0.005 `actor-start`) that had been
  bundled into the same branch — pricing is out of scope for a reliability fix.
- **Also informed by the 2026-08-25 real-cloud investigation** (`fix/workday-jobs-scraper-
  timedout-investigation-0825`, notes.md): two real cloud runs against 8 live Workday tenants
  (default proxy tier and forced `RESIDENTIAL`) both completed well inside budget, and
  `impit`/`curl_cffi` timeout enforcement was independently verified bounded against real
  trickle (not just blackhole) attacks. No live hang was reproduced by that cycle either — this
  fix closes a real, source-confirmed gap in the defense-in-depth, not a reproduced incident.
- Fix: `main()` now delegates to `_run_with_hard_deadline`, which wraps a new
  `_bootstrap_and_scrape` helper covering the actor-start charge, the proxy bootstrap, and the
  scrape-and-push call as one unit — every Apify SDK network call the Actor makes after input
  validation, under a single `ACTOR_HARD_DEADLINE_S` ceiling (unchanged value, 25 min).
- **Not proven to be the sole root cause.** `totalUsers30Days: 1` — the 30-day window is
  dominated by a single customer we cannot see the input of. The leading hypothesis across 5+
  fix cycles (08-13 through 08-25) remains that a small number of repeated runs from that one
  customer, not a live code bug, keeps the ratio TIMED-OUT-dominant. Re-check
  `publicActorRunStats30Days` after 2026-09-20 (30 days clear of this deploy) — if TIMED-OUT
  still dominates on a fully-post-0.6 window, this gap wasn't the (or the only) cause and the
  next lever is restricting `apifyProxyGroups` away from `RESIDENTIAL` for this target (the
  08-25 investigation measured it 2.25x slower and saw one transient `590` tunnel failure that
  never occurred on the default tier).
- Local: ruff clean, pyright clean, full suite green (2 new tests — hang stand-ins injected at
  `_charge` and `_build_proxy_factory` now correctly trip the hard deadline instead of hanging
  unbounded).

### 0.5 — 2026-08-17

#### Fixed

- **`locationFilter` silently matched nothing on ~44% of real tenants sampled, even when
  the location clearly existed.** `publicActorRunStats30Days` still read 26 TIMED-OUT / 4
  SUCCEEDED / 30 total customer runs (86.7% "failed") going into this cycle — the worst in
  the fleet. Per `reference-fleet-health-signal`, our own runs can't see customer logs, so
  this cycle ran a reproduction campaign instead: resolved and verified 9 real, live,
  diverse Workday tenants (nvidia/wd5, adobe/wd5, salesforce/wd12, cisco/wd5, workday/wd5,
  novartis/wd3, roche/wd3, sanofi/wd3, autodesk/wd1 — spanning 4 datacenters and job counts
  from 344 to 2000) and ran the actual `src.scraper.run()` against every one, direct
  connection, zero platform cost.
- Found via that campaign: `src/facets.py`'s `LOCATION_FACET_SEARCH_ORDER` only recognised
  `locationHierarchy1`/`locations`/`locationHierarchy2` — the shape confirmed live against
  NVIDIA alone at implementation time (0.0). Each Workday tenant customises its own facet
  configuration; adobe, sanofi, autodesk, and novartis (4 of the 9 sampled) instead nest
  their country facet under `locationCountry`, a key the search order never checked. Every
  `locationFilter` value on those tenants raised `LocationFilterNoMatchError` — logged and
  the site skipped (REQ-7's designed degradation), not a crash — but a customer filtering
  by country against any of these real, large employers got zero rows regardless of
  whether the location existed. Reproduced live: `resolve_location_applied_facets(<real
  novartis facets tree>, location_filter="France")` raised `LocationFilterNoMatchError`
  with `available locationHierarchy1: []` even though novartis has 17 France postings.
  This is the same "one shape assumed universal" trap `reference-fleet-fault-isolation-
  pattern` warns about, just surfacing as a silent empty result instead of a crash.
- Fix: appended (not inserted) `"locationCountry"` to `LOCATION_FACET_SEARCH_ORDER` —
  additive only, so the original REQ-6 priority order and every already-working tenant
  (nvidia, cisco, roche, salesforce, workday) are unaffected; verified via a new priority
  regression test (a tenant exposing both an earlier-priority group and `locationCountry`
  for the same term still resolves via the earlier group). New fixture
  `tests/fixtures/novartis_jobs_page0.json` — a real, live-captured facets tree — backs 2
  new regression tests in `tests/test_facets.py`. Re-ran the France-filter repro post-fix
  across 5 real tenants (novartis, roche, sanofi, autodesk, adobe): all 5 now return
  matching rows (novartis 17, sanofi 20, roche 14, autodesk 4, adobe 4) where novartis and
  sanofi previously returned 0.
- **The TIMED-OUT signature itself (26/30 customer runs) could NOT be reproduced locally
  this cycle**, and this fix is not asserted to be its root cause — see
  `docs/specs/workday-jobs-scraper/notes.md` 2026-08-17 entry for the full audit trail:
  every unbounded-await candidate in `src/`, the Apify SDK's own `ProxyConfiguration`
  (`_check_access`, `new_url`), and `apify_client`'s `impit`-based HTTP layer used by
  `Actor.push_data`/`Actor.charge` was independently tested against synthetic hangs
  (black-hole TCP servers, direct and through a stalled proxy) and confirmed correctly
  bounded / correctly cancellable via `asyncio.wait_for` — no lingering non-daemon threads,
  no hang in `session.close()` after a cancelled in-flight request. The leading
  **unconfirmed hypothesis**, unchanged from the 2026-08-13/08-15 entries: the 30-day
  window still contains runs that predate the 0.4.0 (Aug 13) hard-deadline fix and the
  0.4.2 (Aug 15) finite-default-cap fix. This needs the window to roll forward and be
  re-checked — it cannot be confirmed or ruled out from this account's read-only API
  access to `publicActorRunStats30Days` (no per-run timestamps).
- Local: ruff clean, pyright clean, 85/85 pytest green (2 new). No cloud run this cycle
  (explicit budget constraint — account at $3.79/$5.00, cloud runs withdrawn mid-cycle).

### 0.4 — 2026-08-13

#### Fixed

- **Defense-in-depth against TIMED-OUT: nothing bounded the Apify SDK I/O layered on top
  of the scraping loop, or the proxy bootstrap before it.** `publicActorRunStats30Days`
  read `{TIMED-OUT: 24, SUCCEEDED: 1, TOTAL: 25}` — the worst health number in the fleet.
  Investigation (read-only `GET /v2/acts/DevilScrapes~workday-jobs-scraper`, no cloud runs —
  account was over its FREE-tier monthly cap): the live build (0.3.1, deployed + cloud-QA
  PASSed 2026-08-11) already carries every hang-bound this Actor has needed so far —
  `RunBudget` (20 min wall-clock, checked before every site/page/detail-GET),
  `client.py`'s per-request `HTTP_TIMEOUT_S` x `MAX_RETRIES` (~130s worst case per call),
  and `MAX_PAGES_PER_SITE`. A full line-by-line audit of every remaining `await` in
  `src/` found no other unbounded network wait — `crawlee.ProxyConfiguration.new_url()`
  is pure local string construction (confirmed by reading the vendored source, zero I/O),
  and `Actor.create_proxy_configuration()`'s own access-check is bounded to ~20s by the
  Apify SDK itself. `totalRuns` (33 lifetime) vs. the 30-day customer-run window (25) shows
  this Actor is only 23 days old, so the 24 TIMED-OUT figure is most plausibly dominated by
  runs that predate the 08-06/08-11 fixes, still inside the trailing 30-day window — but
  that could not be proven without a cloud run, which the account's exhausted monthly
  budget ruled out this cycle.
- What COULD be verified and fixed locally: `_scrape_and_push` (`src/main.py`) — the loop
  that drains `scraper.run()` and calls `Actor.push_data()`/`Actor.charge()` between
  pages — runs entirely outside `RunBudget`'s reach; so does proxy bootstrap
  (`_build_proxy_factory`), which happens before `RunBudget` is even constructed. A hang in
  either (a real Apify SDK network call, unlike `new_url()`) had **no bound at all** before
  this fix — it would run until the platform's own ~3600s default timeout silently killed
  it as TIMED-OUT, with zero actor-authored explanation in the log.
- Added `_scrape_with_hard_deadline()` (`src/main.py`), which wraps the whole
  `_scrape_and_push()` call in `asyncio.wait_for(timeout=ACTOR_HARD_DEADLINE_S)`
  (`RUN_BUDGET_S + 5 min` = 25 min — comfortably above the internal scraping budget so it
  never races it, comfortably below the platform's 3600s default so the Actor always
  self-terminates first). On timeout: `logger.error(...)`, a clear
  `Actor.set_status_message(...)`, then `raise SystemExit(1)` — the run ends `FAILED` with
  an explicit cause instead of an opaque platform `TIMED-OUT`.
- New regression tests (`tests/test_main.py`):
  `test_scrape_with_hard_deadline_fails_loud_instead_of_hanging_forever`,
  `test_scrape_with_hard_deadline_returns_normally_when_fast`,
  `test_actor_hard_deadline_leaves_a_generous_margin_over_the_scraping_budget`. The first
  demonstrated the gap before the fix (an `asyncio.Event` that never resolves —
  `asyncio.sleep` doesn't work as the hang stand-in here, since `tests/conftest.py`'s
  autouse `_patch_sleep` fixture mocks it globally on the shared `asyncio` module object,
  not just inside `src.client`).
- Local: ruff clean, pyright clean (`uv run pyright`), 83/83 pytest green (3 new),
  `scripts/verify_input_prefill.py workday-jobs-scraper` OK, `apify run` against the QA
  fixture exits 0 (`Done — 4 job posting(s) across 1/2 site(s).`, direct routing —
  Apify Proxy itself returned "Monthly usage hard limit exceeded", expected given the
  account's exhausted budget).
- **Needs cloud verification on/after 2026-08-15** (account budget resets): confirm build
  0.4 still QA-PASSes with the same fixture, and re-pull
  `publicActorRunStats30Days` a few days after 0.4 has been live to see whether the
  TIMED-OUT rate actually drops — the historical-window hypothesis above needs the
  30-day count to visibly move, not just a clean QA run, before this can be called closed.

### 0.3 — 2026-08-11

#### Fixed

- **One malformed `careerSites` entry crashed the entire run before any site was
  scraped (30-day `publicActorRunStats30Days` health: 4%, 1/23 customer runs
  succeeded).** `ActorInput`'s `career_sites` field validator (`src/models.py`)
  parsed every string entry via `siteurl.parse_career_site_url()` eagerly, and let
  any single `ValueError` (a non-`*.myworkdayjobs.com` host — e.g. a custom
  career-site domain the customer's browser shows, a direct job-posting permalink
  with extra path segments, a typo) raise straight out as a Pydantic
  `ValidationError`, which `main.py` turns into `SystemExit(1)` — before the
  Actor ever opens a network connection. Because `careerSites` is explicitly a
  batch field (up to 1000 entries — the actor's core supported use case), one bad
  entry anywhere in a customer's list failed **every** site in that run, not just
  the bad one. This is the same "recoverable error crashes the whole run" bug
  class already fixed for network failures in `src/scraper.py` (0.1.0) — just one
  layer earlier, at input validation, which the 0.1.0/0.1.1 fixes didn't touch.
  Reproduced locally: `ActorInput.model_validate({"careerSites": ["nvidia.wd5....",
  "careers.some-fake-corp.example.com/jobs", "adobe.wd5...."]})` raised
  `ValidationError` and killed the run even though 2 of the 3 entries were valid,
  live Workday tenants (`apify run` end-to-end repro against real nvidia + adobe
  career sites plus one bogus custom-domain entry: before the fix, exit 1, zero
  rows; after, exit 0, 4 real job rows from both good sites).
- `_parse_string_entries` (`src/models.py`) now parses each string entry
  independently, logs a `WARNING` naming every entry that fails to parse, and
  drops only those entries — the good entries in the same batch still validate
  and get scraped. Validation still fails loud (as REQ-1 originally specified)
  only when **every** entry in the batch is unparseable, so a single-entry input
  with a bad URL keeps its original "fail loud, name the bad entry" behaviour
  verbatim.
- New regression tests: `test_one_bad_entry_among_many_good_ones_is_skipped_not_fatal`,
  `test_all_entries_bad_still_raises_validation_error_with_every_message`
  (`tests/test_models.py`).

### 0.1.1 — 2026-08-05

#### Fixed

- **Every run timing out (30-day success rate 0%, 16/16 TIMED-OUT).** The
  2026-07-30 fix stopped a blocked/rate-limited site from *crashing* the
  whole run, but a genuinely blocked or slow tenant now simply pays its full
  \~130s retry-exhaustion cost (`HTTP_TIMEOUT_S x MAX_RETRIES` + backoff,
  `src/client.py`) per HTTP call before being skipped — and nothing bounded
  the *sum* of those worst cases across up to 1000 `careerSites` entries x
  `MAX_PAGES_PER_SITE` pages x per-job detail GETs (`includeDescription`).
  A run hitting several slow/blocked tenants in one input could legitimately
  keep "working" well past Apify's platform run timeout; the platform then
  kills it (TIMED-OUT) instead of the Actor finishing cleanly with whatever
  it collected.
- Added `RunBudget` (`src/scraper.py`) — a run-wide wall-clock deadline
  (`RUN_BUDGET_S = 20 min`) checked before every site, every page, and every
  per-job detail GET. Once exceeded, `scraper.run()` stops yielding further
  rows (never raises); `main.py` still pushes whatever was already batched
  and appends a clear note to the REQ-5 status message
  (`_build_status_message(..., budget_exceeded=True)`) instead of silently
  returning a partial dataset. Confirmed live end-to-end: a two-site input
  where both sites point at a socket that accepts-but-never-responds now
  skips the second site entirely once the budget is spent, rather than
  paying its full retry-exhaustion cost too.
- `HTTP_TIMEOUT_S`-per-request bounding in `client.py` was already correct
  (confirmed via a local black-hole-server reproduction — curl-cffi aborts
  at exactly 20s per attempt); the missing piece was purely the run-wide
  ceiling, not the per-request one.
- New regression tests: `test_run_budget_check_latches_true_once_deadline_passes`,
  `test_run_stops_before_any_site_when_budget_already_exceeded`,
  `test_run_stops_mid_pagination_when_budget_exceeded_between_pages`,
  `test_run_stops_mid_page_detail_enrichment_when_budget_exceeded`
  (`test_scraper.py`), plus `test_build_status_message_budget_exceeded_appends_clear_note`
  and `test_scrape_and_push_forwards_budget_to_scraper_run` (`test_main.py`).

### 0.1.0 — 2026-07-30

#### Fixed

- **Whole-run crash on a single blocked/rate-limited career site.** REQ-11
  says a request that exhausts retries (persistent `429`/`5xx`/network error)
  must be "treated as a failed page/site" — same as REQ-4's known-bad-tenant
  handling. `src/client.py` correctly raised a bare `RuntimeError` once its 5
  retries were exhausted, but `src/scraper.py` only caught the narrower
  `WorkdaySiteError` (404 / `errorCode`) around both the pagination fetch
  (`_rows_for_site`) and the `locationFilter` facet-discovery call
  (`_resolve_applied_facets`). Any site that failed with an unhandled
  `RuntimeError` propagated out of `scraper.run()` uncaught, crashing the
  entire Actor run — including every *other* `careerSites` entry that would
  otherwise have succeeded. This produced the 30-day 0% success rate
  (10/10 runs failed): real customer inputs hitting a Workday tenant with
  stricter anti-bot posture than the NVIDIA tenant used for initial QA now
  fail that one site only; other sites in the same run still complete.
- Added `_log_retry_exhausted()` helper + two regression tests
  (`test_run_continues_to_next_site_after_retry_exhausted_runtime_error`,
  `test_run_continues_to_next_site_after_discovery_retry_exhausted`) that
  reproduce a retry-exhausted `RuntimeError` from `client.list_jobs()` and
  assert the run degrades to skipping that site instead of raising.

### 0.0 — 2026-07-21

- Initial release. Real NVIDIA job rows via Workday's keyless `cxs` JSON API.
