Connect Services
Recorded Responses and the Marker You Move Too Early
Replaying a service's real answers from a recording so the job can be exercised with no network at all, and advancing the position marker only once a run has completely succeeded.
Lesson 11 of 18 in the recommended order · About 25 min (estimate)
On this page
Workflow automation glossary — terms and common confusions
- Dry run
- A run that reports every change it would make and makes none of them.
A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.
- Idempotent
- Running it again leaves the same result as running it once.
"It did not crash the second time" is not the same property; check what the second run changed.
- Run record
- The stored facts about one execution: when it started, what it read, what it changed, how it ended.
A run record is not the log. The log is prose for a person; the record is data the next run reads.
- Transient failure
- A failure that a later identical attempt could succeed at, such as a timeout.
A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.
- Backoff
- Waiting longer between successive retries instead of retrying immediately.
Backoff without a maximum attempt count is an unbounded loop with a politeness delay.
- Request budget
- A hard cap on how many requests one run may make, checked before each request.
A page limit is not a budget if a retry can make extra requests the limit never counts.
- Quarantine
- Setting aside a record a run could not process, with the reason, so the run continues.
Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.
- Reconciliation
- Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.
A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.
- Trigger
- The event or time that causes a run to start.
Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.
- Heartbeat
- A signal a healthy run emits, whose absence is itself the alert.
Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.
- Staleness
- How old the most recent successful run is, compared with how old it is allowed to be.
A green last run is not freshness. Ask when it ran, not whether it passed.
- Atomic replace
- Writing output to a temporary name and renaming it into place in one step.
A reader never sees a half-written file; an interrupted run leaves the previous output intact.
- Transport
- The injected callable that actually performs a request, separate from the client that interprets it.
A client that builds its own connection cannot be tested without a network, which is why the seam exists.
- In-memory filesystem
- The filesystem this course’s browser exercises operate on, which lives only inside the tab.
It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.
Outcome
By the end of this lesson you can drive a job entirely from recorded service responses, with no network involved, and you can write the part of an incremental fetch that decides when it is safe to remember where you got to.
Why it matters
There are two problems here, and the second one is the one that loses data.
The first: a job can be tested against a live service, but live tests are slow or variable and rarely produce a particular failure on demand. Recorded or written fixtures let a test repeat exact responses without network access. Refresh real recordings when the service changes; this course uses only hand-written fixtures. Other integration tests can still check the live boundary.
The second problem is the reason incremental fetching exists and the reason it goes wrong. A job that asks for everything, every time, is simple and eventually too slow: an hourly job re-reading four years of records is doing four years of work per hour. So it remembers a marker — the timestamp or token it got to last time — and asks only for what came after.
Now the failure. The run fetches records after 2026-03-01, gets four, writes the marker forward to 2026-03-03, and then fails while writing the report. Tomorrow it asks for records after 2026-03-03. Those four records are gone: nothing will ever ask for them again, no error was raised about them, and the only evidence is a gap that nobody is looking for.
For this one-batch example, the marker is a claim that the fetched records have been handled. Move it before that is true and the next request can skip work.
Concept
A transport is the seam. One function, injected: it takes the request and returns the response, and it is the only part of the job that would touch a network. Everything above it — deciding what to ask for, interpreting what comes back, handling an error — is ordinary code that runs anywhere.
def make_recorded_transport(recorded):
"""Replays stored answers. Raises when asked for something not recorded."""
def transport(request):
if request not in recorded:
raise LookupError(f"no recorded response for {request!r}")
return recorded[request]
return transport
The raise matters more than it looks. A recorded transport that returns an empty result for an unrecorded request turns "this test is exercising a path nobody recorded" into "this path returns nothing", and the test passes while proving nothing. Missing recordings must be loud.
Recording, and what to take out. A recording is the response a service really gave, saved as a file. It beats a hand-written stub because it has the shape the service actually produces — the field that is sometimes absent, the number that arrives as a string, the extra key nobody documented.
Before storing one, remove anything that would authorise a request or identify a person: whatever your provider's documentation says its authentication mechanism is, plus names, addresses, and identifiers belonging to real people. Store the recording next to the code, and note where and when it came from, so somebody can tell whether it still resembles the service.
This course records nothing, because it makes no requests. Every response in it is written by hand as a deterministic fixture, and no lesson, exercise, or project here requires an account of any kind.
The marker. Sometimes a timestamp, sometimes an opaque token the service hands back. Two rules:
- Store what the service gave you, not what you calculated. If the response says "next: abc123", store
abc123. A marker you derived — the newest timestamp you saw, plus one second — can skip a record that arrives late with an earlier timestamp, which is common and normal. - Return the next marker only after this batch has been published. Not after the fetch or parse. The worked function returns an in-memory marker; a caller that persists it also needs an atomic state update and a safe retry policy.
Repeating is safer than skipping when the publisher is idempotent. A failed batch that leaves the marker alone can fetch the same records again. If publishing partly succeeded before it failed, repeating can duplicate effects unless the publisher makes retries safe. The earlier idempotency work is what gives this ordering a recovery path.
Read the code
RECORDED = {
"2026-05-01": {"records": ["a", "b"], "next": "2026-05-02"},
"2026-05-02": {"records": ["c"], "next": "2026-05-04"},
"2026-05-04": {"records": [], "next": "2026-05-04"},
}
def make_recorded_transport(recorded):
def transport(since):
if since not in recorded:
raise LookupError(f"no recorded response for since={since}")
return recorded[since]
return transport
def run_once(transport, marker, publish):
"""Fetch since marker, publish, and only then move the marker."""
try:
response = transport(marker)
records = response["records"]
next_marker = response["next"]
publish(records)
except Exception as error:
return 0, marker, f"failed: {type(error).__name__}"
return len(records), next_marker, "ok"
published = []
transport = make_recorded_transport(RECORDED)
marker = "2026-05-01"
for attempt in range(3):
count, marker, status = run_once(transport, marker, published.extend)
print(f"run {attempt + 1}: {count} new, marker {marker}, {status}")
print("published:", ",".join(published))
publish is injected for the same reason the transport is: the interesting failure in this lesson happens after the fetch and before the marker moves, and that failure cannot be demonstrated unless the publishing step is something a test controls.
The function reads the required response["next"] key inside the try but returns it only after publish succeeds. The caller then replaces its local marker with that returned value. The final response returns the marker unchanged along with no records, which is how this scripted service says "nothing new".
The except returns the old in-memory marker for a failed fetch, malformed response, or publisher exception. This function does not write a durable checkpoint. A publisher that has already produced some effects can still raise, so replay also requires idempotent publishing.
The marker gate for one fetched batch
Return a new marker only after the required response keys can be read and publish returns
This worked function returns a marker to its caller. It does not save a durable checkpoint.
Success path
Start- Hold the old marker
Fetch and read keys- Read records and the service's next marker
Publish- Pass the batch to the injected publisher
Return- Only now return the next marker
The caller may then decide how to persist the returned marker.
Failure path
Fetch, shape, or publish error- Return the old in-memory marker and a failed status
Retry boundary- The same batch may be fetched again
A publisher may partly succeed before raising. Repeating safely requires idempotent or transactional effects.
Predict the output
Predict every line.
Check your prediction
run 1: 2 new, marker 2026-05-02, ok
run 2: 1 new, marker 2026-05-04, ok
run 3: 0 new, marker 2026-05-04, ok
published: a,b,c
The third run is not a failure. It asked for records after 2026-05-04, the service said there are none and returned the same marker, and the correct report is a successful run that did nothing. A job that treats this as an error alerts every quiet weekend.
Modify the code
Make the publish step fail on the second run, by passing a function that raises:
def failing_publish(records):
raise IOError("report volume unavailable")
Use it for run 2 only, and keep published.extend for runs 1 and 3.
What changes, and why
run 1: 2 new, marker 2026-05-02, ok
run 2: 0 new, marker 2026-05-02, failed: OSError
run 3: 1 new, marker 2026-05-04, ok
published: a,b,c
Run 2 fetched c, could not publish it, and left the marker at 2026-05-02. Run 3 therefore asked for records after 2026-05-02 again, got c a second time, and published it. Nothing was lost, and published holds exactly what it held before.
IOError reports as OSError because Python has made them the same class since 3.3, which is worth knowing before you write an except IOError expecting it to be narrower than it is.
As a counterfactual, assign the local marker = response["next"] inside run_once before publish, and have the except return that local variable. Then run 2 fails with the marker already at 2026-05-04, run 3 fetches nothing, and c is never published by anyone. The output still looks orderly, one record is permanently missing, and nothing anywhere reports it.
Debug the bug
An assistant was asked for "an incremental sync that remembers where it got to". It produced this.
def sync(transport, state_file):
marker = state_file.read_text() if state_file.exists() else "1970-01-01"
state_file.write_text(datetime.date.today().isoformat())
try:
response = transport(marker)
except Exception:
return []
return response.get("records", [])
What's actually wrong
Every line of the state handling is wrong, in a different way.
- The marker is written before the fetch. Not merely before the processing — before the request. A run that fails for any reason has already recorded that it got to today, so everything it did not fetch is skipped forever.
- The marker written is today's date, not what the service returned. The service's own idea of "next" is discarded, so any record the service would have returned with an earlier timestamp — a late arrival, a correction, a record whose clock differs from yours — falls behind the marker and is never fetched.
except Exception: return []turns any failure into a successful empty run. Combined with defect 1, a service that is down for a day produces "no new records" every run, and the marker keeps advancing over data that was never read.response.get("records", [])does the same thing for a response of an unexpected shape: a service that changed its field name reports zero records forever, successfully.- The state file is written unconditionally and unprotected, so a crash partway through the write leaves a truncated marker that the next run reads as a date.
The ordering needed for a safe marker:
def sync(transport, state, publish):
marker = state.read(default="1970-01-01")
response = transport(marker) # a failure here raises, and the marker stays
records = response["records"] # a missing key raises, rather than reporting zero
next_marker = response["next"] # read required key before publishing
publish(records) # may partially succeed before raising
state.replace_atomically(next_marker) # required state contract: old or new, never partial
return len(records), next_marker
Nothing is caught here, so failures in fetch, required-key lookup, or publish stop before the marker update. The state abstraction must guarantee an atomic replacement; an ordinary file write can leave a partial marker if interrupted. Publishing may also have partly succeeded before raising, so retrying safely requires idempotent or transactional effects. The example shows ordering and a required state contract, not an all-or-nothing transaction across publishing and storage.
Try it yourself
Write run_once, which fetches from a recorded transport, publishes, and returns the marker to store. It is exercised against two supplied recordings: one that works for three consecutive runs, and one whose service fails. In these fixtures, the publisher refuses before writing, so failure publishes nothing; the returned marker must remain old. A different publisher may have partial effects before it raises.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: find the gap a marker cannot close.
Suppose the service orders records by the time it received them, and a record can be recorded late — it arrives today carrying yesterday's timestamp. Work out what a marker based on that timestamp does with it, then write down two ways to stop losing it.
What a good answer looks like
With a strict timestamp-based query, the late record can fall behind the marker and be missed. This data-loss bug can be invisible: the record exists at the service, it never arrives, and nothing errors.
Two answers, both used in practice. Overlap: subtract a safety window from the stored marker before asking — fetch from "marker minus one hour" — and rely on the processing being idempotent to absorb the records that arrive twice. Or use the service's own cursor rather than a timestamp, when it offers one, because a cursor is the service's statement about its own ordering and a timestamp is your inference about it.
Both depend on re-processing being harmless, which is the same property the first module built, and is what makes it safe to prefer repetition over skipping.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why must a recorded transport raise when asked for a request nobody recorded?
- Why store the marker the service returned rather than one you computed?
- Where in a run is it safe to advance the marker?
- A run fails and repeats the same twenty records tomorrow. What property makes that harmless, and where did this course build it?
Answers
- Returning an empty result instead would turn "no recording exists for this path" into "this path produces nothing", so a test exercising an unrecorded path passes while proving nothing.
- A computed marker is your inference about the service's ordering. A record that arrives late, carrying an earlier timestamp, falls behind an inferred marker and is never fetched. The service's own token is its statement about its own ordering.
- Return the next marker after this batch is published, not after fetching. Persist it atomically, and make publishing safe to repeat if it partly succeeded before a failure.
- Idempotency: processing the same record twice leaves the same result as processing it once. It was built in the first module, with the upsert-style load and the plan-then-apply split, and this is where it pays for itself.
Sign in to track your progress on this exercise.
Summary and next step
Inject the transport, drive it from recorded responses, and make a missing recording raise. Return the service's marker only after this batch publishes; persist that marker atomically. If a failure leaves the old marker in place, a retry may repeat work, so publishing must be idempotent or transactional. Next: the same transport, asked for many pages, under a budget that retries and pagination have to share.