Skip to main content
Learning Center
Workflow Automation

Make It Dependable

Failing Halfway, and Starting From There

Telling a failure that a later attempt could survive from one that will fail identically forever, picking up from what the last run recorded rather than from the beginning, and refusing to let one unprocessable record block the queue.

Lesson 14 of 18 in the recommended order · About 30 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 decide which failures are worth trying again, resume a run from the point the last one recorded rather than from the beginning, and make sure that a single record which can never be processed is set aside instead of stopping everything behind it.

Why it matters

Retrying is not a virtue. It is the right response to one specific kind of failure and the wrong response to every other kind.

A connection that timed out might succeed in thirty seconds. A record whose date field says not applicable will fail identically in thirty seconds, in thirty minutes, and every night for the next four years. Retrying the first is patience; retrying the second is a job that never finishes and a service that gets hammered on behalf of a record nobody will ever fix.

Resumption has a mirror-image failure. A run that processes 900 of 1000 records and dies has done real work, and a job that starts from zero tomorrow does that work again — which is merely wasteful if processing is idempotent, and is duplicate invoices if it is not. Meanwhile the run that starts from record 901 without checking anything is assuming the first 900 were finished rather than merely attempted.

And there is the case that combines them. One record in the middle cannot be processed, ever. The job fails on it, resumes at it tomorrow, fails on it again, and every record behind it waits forever. The queue is not slow; it is stopped, and the summary says "failed" every morning until somebody reads far enough to find out why.

Concept

Transient or permanent. The question is not how bad the failure is, but whether an identical attempt later could succeed.

| Failure | Kind | Because | | ---------------------------------- | ---------------------- | ---------------------------------------------------------- | | Connection timed out | Transient | The service may answer next time | | Service returned 503 | Transient | It is saying so itself | | Rate limited | Transient | With the published wait, yes | | Record is missing a required field | Permanent | The record will not change by being retried | | Output folder does not exist | Permanent for this run | Retrying will not create it; a person or a setup step will | | Authorisation was refused | Permanent for this run | Retrying an unauthorised request just repeats it |

Classify by exception type, not by matching text in a message. Messages change between library versions and translations; types do not. Where a service reports the distinction in a status code, use the code.

The default for anything unclassified is do not retry. A retry loop that treats every unknown failure as transient turns a bug in your own code into an infinite loop, which is how a TypeError becomes a job that runs all night.

Resume from the record, not from a counter. The previous run's record holds what was completed, and that is what the next run subtracts from the batch. Two properties matter:

  • It records completion, not attempts. A record that was started and not finished must not appear, or the resumed run skips it.
  • Resumption is only safe when processing is idempotent. It is always possible for a record to be completed and the run to die before the record says so, in which case the next run processes it again. That is unavoidable, it is fine, and it is fine only because the first module built the property that makes it fine.

Bound the attempts on one record. Keep a per-record attempt count. After a small number of failed attempts — two or three — move the record to quarantine with the reason, and carry on with the rest. The queue keeps moving, the record is preserved for a person, and the run reports both facts.

This is the difference between a job that says "failed" every morning and one that says "completed, 1 record quarantined: INV-88 has no supplier id". The second is a message somebody can act on in a minute.

Report what actually happened. A resumed run's record carries what it skipped, what it processed, and what it quarantined. "Processed 100 records" from a run that skipped 900 and processed 100 is true and useless; the skipped count is what makes it interpretable.

Read the code

class RecordRejected(Exception):
    """This record cannot be processed, now or later."""


class ServiceUnavailable(Exception):
    """This attempt failed; a later identical one might not."""


TRANSIENT = (ServiceUnavailable, TimeoutError)


def process_batch(previous, batch, handle, max_attempts=2):
    """Process what the previous run did not, quarantining what cannot be processed."""
    done = list(previous["processed"]) if previous else []
    skipped = sum(1 for item in batch if item in done)
    quarantined = []
    for item in batch:
        if item in done:
            continue
        for attempt in range(1, max_attempts + 1):
            try:
                handle(item)
                done.append(item)
                break
            except TRANSIENT:
                if attempt == max_attempts:
                    return {"status": "partial", "processed": done, "skipped": skipped,
                            "quarantined": quarantined, "reason": f"gave up on {item}"}
            except RecordRejected as rejection:
                quarantined.append({"id": item, "reason": str(rejection)})
                break
    return {"status": "completed", "processed": done, "skipped": skipped,
            "quarantined": quarantined, "reason": ""}


def handle(item):
    if item == "C":
        raise RecordRejected("no supplier id")
    if item == "E":
        raise ServiceUnavailable("upstream down")


record = process_batch(None, ["A", "B", "C", "D", "E"], handle)
print("status:", record["status"])
print("processed:", ",".join(record["processed"]))
print("quarantined:", ",".join(entry["id"] for entry in record["quarantined"]))
print("reason:", record["reason"])

The two exception classes carry the classification, so the decision is made where the failure is raised — by the code that knows what went wrong — rather than by a caller inspecting a message.

break after a successful handle leaves the retry loop; break after a RecordRejected leaves it too, because there is nothing to retry. Only the transient branch loops.

A transient failure that exhausts its attempts returns immediately with partial, because the service is unwell and the remaining records would each spend their attempts discovering the same thing. A permanent failure does not return: it is quarantined and the loop carries on, which is what keeps one bad record from stopping the queue.

Predict the output

Predict all four lines.

Check your prediction
status: partial
processed: A,B,D
quarantined: C
reason: gave up on E

A and B succeed. C raises RecordRejected, is quarantined, and the loop continues — this is the behaviour that matters. D succeeds. E raises ServiceUnavailable twice, exhausts its attempts, and the run returns partial.

processed holds A,B,D and not C: a quarantined record was not processed, and putting it in processed would tell tomorrow's run it was done.

Completed IDs carry forward; failed attempts do not

1. Shown first call: no previous record

  1. Adone
  2. Bdone
  3. Cheld
  4. Ddone
  5. E2 tries
A, B, D
Success → append to processed
C
RecordRejected: no supplier id → quarantine → continue
E
Transient failure → retry → second failure → return partial

Returns processed A,B,D; skipped 0; quarantined C; reason: gave up on E.

2. Hypothetical second call: pass the first record

  1. Askip
  2. Bskip
  3. Cheld
  4. Dskip
  5. E2 tries
A, B, D
Already processed → skip; no handle calls
C
Still no supplier id → new per-run quarantine entry
E
Two new transient failures → return partial again

Same batch, handler and max_attempts=2. Returns processed A,B,D; skipped 3; quarantined C; reason: gave up on E.

“Held” means quarantined in that call’s returned record, not permanently removed from the batch. E has four failed attempts across these two calls: the bound resets per call. This function returns an in-memory record; it does not write a checkpoint file. A safe caller must persist confirmed completion and keep processing idempotent.

Modify the code

Change TRANSIENT to (ServiceUnavailable, TimeoutError, RecordRejected), so every failure is treated as retryable.

What changes, and why
status: partial
processed: A,B
quarantined:
reason: gave up on C

C is now retried twice, fails identically twice, and stops the run. D is never attempted even though it would have succeeded, and E is never reached. One malformed record has taken the whole queue down.

Tomorrow the resumed run starts at C, retries it twice, fails, and stops. It will do that every night, reporting partial each time, until a person reads the reason. Nothing has crashed and nothing is being retried unreasonably often — the job is simply never going to make progress again.

That is why "retry everything" is not a safe default dressed up as caution. It converts a single bad record into a permanent outage of the whole job.

Debug the bug

An assistant was asked to "make the job resume where it left off if it crashes". It produced this.

def run(batch, state_path):
    last_index = int(state_path.read_text()) if state_path.exists() else 0
    for index, item in enumerate(batch):
        if index < last_index:
            continue
        state_path.write_text(str(index))
        for attempt in range(10):
            try:
                handle(item)
                break
            except Exception:
                time.sleep(1)
    print("done")
What's actually wrong
  1. The position is an index into a list that will not be the same list tomorrow. Tomorrow's batch has new records in it, possibly in a different order. Index 900 is a different record, so the resumed run skips whatever now occupies positions 0 to 899. Resume on identity — the record's id — never on position.
  2. The state is written before the record is handled. Index 900 is recorded as reached, then handle runs. A crash inside handle leaves the state saying 900 was done, and it was not.
  3. Ten attempts on any exception. A record that can never be processed is retried ten times, every night, sleeping between each. A TypeError in handle gets the same treatment.
  4. The retry loop swallows the final failure. After ten attempts the loop simply ends and the code moves to the next record as though nothing happened. The record is neither processed nor quarantined nor reported — it has silently vanished from the run.
  5. "done" is printed unconditionally, so a run in which every record failed ten times reports the same word as a clean run.
  6. No counts and no record. Nothing here can answer how many were skipped, processed, or lost.

The version that resumes correctly:

def run(previous, batch, handle, max_attempts=2):
    done = set(previous["processed"]) if previous else set()
    processed, quarantined = list(previous["processed"]) if previous else [], []
    for item in batch:
        if item["id"] in done:
            continue
        outcome = attempt_record(item, handle, max_attempts)   # raises nothing
        if outcome["status"] == "processed":
            processed.append(item["id"])                       # after the work, not before
        elif outcome["status"] == "rejected":
            quarantined.append({"id": item["id"], "reason": outcome["reason"]})
        else:
            return partial_record(processed, quarantined, f"gave up on {item['id']}")
    return complete_record(processed, quarantined)

Resumption is by identity. The id is appended after the work succeeded, not before it starts. Attempts are bounded and only transient failures use them. A permanently failing record is quarantined by name and the queue continues. Every path produces a record, and no path prints a word that was not derived from what happened.

Try it yourself

Write run, which processes a batch, skipping what a previous run recorded as done and stopping when a supplied failure point is reached. It is called twice: once with no previous run and an early failure, then again with the record the first call produced.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: decide what happens on the fourth failure of one record.

The concept section says to quarantine a record after two or three failed attempts. Attempts across runs, though, need somewhere to live: the count has to survive the run that failed. Design where that count is stored, and write down what the job does when a record reaches its limit — and what it does if the same record reappears in tomorrow's batch.

What a good answer looks like

The count belongs with the record's identity, not with the run: a small map of record id -> failed attempts kept alongside the run records. That is what makes "this record has failed three nights running" a fact rather than an impression.

The reappearance question is the interesting one, and the honest answer is that a quarantined record should not silently re-enter the queue on its own. If it does, and the underlying problem has not been fixed, the job spends its attempts on it again every night. Either the quarantine is respected until somebody clears it, or the retry interval grows — but "try again from scratch tomorrow" gets you the blocked queue back in a slower form.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What single question separates a transient failure from a permanent one?
  2. Why classify by exception type rather than by matching the error message?
  3. Why resume from record identities rather than from an index or offset?
  4. Why must a permanently failing record be quarantined rather than retried indefinitely?
Answers
  1. Could an identical attempt later succeed? Not how serious it is, and not whose fault it is.
  2. Messages change between library versions and translations, and a text match silently stops matching. Types are part of the interface and change deliberately.
  3. Tomorrow's batch is not the same list. New records shift every position, so index 900 refers to a different record and everything now before it is skipped.
  4. Otherwise every record behind it waits forever. The job reports a failure every morning and never makes progress, which is an outage that looks like a retry policy working correctly.

Sign in to track your progress on this exercise.

Summary and next step

Retry only what a later identical attempt could survive, decide that by exception type, and default anything unclassified to not retrying. Resume from the identities the last run recorded as completed, recorded after the work rather than before it, and rely on idempotency for the record that was finished just before the lights went out. Bound the attempts on any single record so a permanent failure is quarantined by name and the queue keeps moving. The last lesson of this module is about the run that never started: choosing a schedule, stopping two runs from overlapping, and alerting on silence.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.