Skip to main content
Learning Center
Workflow Automation

Connect Services

What an Unattended Job Needs From a Service

Configuring per-call timeout with a whole-run request budget and a pre-request deadline, then naming which gate prevents another call. A separate execution limit is still needed to bound the entire job.

Lesson 10 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 decide, before a job makes its first request, which timeout contract each call needs, how many requests the run may start, and when it must stop starting new calls. You can implement a check that names whether the request budget or the pre-request deadline refused the next call. This check does not itself stop a call already in flight or bound total job duration.

Why it matters

A service you call from a script you are watching is a different thing from a service you call from a job that runs at 3am.

When you are watching, a slow response is annoying and you press Ctrl+C. Unattended, the same slow response is a process that sits there. If a scheduler allows overlapping runs every fifteen minutes, one hung call can leave the next run starting alongside it. Repeated overlaps can exhaust machine resources.

An absent or unsuitable timeout is one way this happens. Client defaults differ: some require an explicit timeout, while others supply one. Check the actual transport contract instead of assuming a default.

The second failure is quieter. A job retries a failing service politely, backing off, and eventually succeeds after two hundred requests. Nothing broke and nothing was reported. Meanwhile the service's owner is looking at a traffic graph wondering who is doing that, and your run took forty minutes instead of forty seconds.

Timeout, budget, and pre-request deadline address different parts of that risk. They do not by themselves impose a hard total-runtime limit on an in-flight request or the entire process.

Concept

Four decisions, made once, at the top of the job.

1. A timeout on every request. Not on the session, not on the retry loop — on the call. Two numbers are worth separating: how long to wait for the connection to be established, and how long to wait for the response once it is. A connect limit and a read-inactivity limit cover different stalls; library-specific timeout settings may still not impose a total response-download deadline.

Nothing in this course makes a real request, so the timeout here is a value carried in the run's settings and passed to whatever transport a learner uses later. Naming it in the settings is the habit; the library-specific spelling is documented by whichever library you end up using, and it is not the same in all of them.

2. A request budget for the whole run. Not a retry count, and not a page limit — a single number covering every request the run makes, whatever it makes them for. Retries spend it. Pagination spends it. A budget is what stops the pathological case where twenty pages each make five total attempts and nobody predicted a hundred requests.

3. A pre-request wall-clock deadline. Stop starting new calls at or after a chosen time, ideally before the next run is scheduled. This gate catches elapsed time that a request counter misses: fifty calls of forty seconds each fit a 50-request budget but take over half an hour. It cannot interrupt a call already in progress or guarantee that the job has finished by the deadline; the transport and scheduler need their own bounds.

4. What "nothing to do" looks like. A service that returns zero records is not broken; it is telling you there is nothing new. A job that treats an empty result as a failure will alert every weekend. A job that treats an error as an empty result will report a quiet, successful, empty run on the day the service is down, which is worse.

Refuse with the reason. Budget and deadline run out for different reasons and need different responses. A spent budget means the run started its allowed number of requests; investigate data volume, pagination, and retries. A passed deadline means the clock reached the configured gate; investigate request duration, waits, and other work. Reporting "stopped" for both leaves the person reading it no better off, so the check returns which one it was.

Injecting the clock. A deadline is a comparison against the current time, and a test that has to wait for real time to pass is a test nobody runs. Pass the clock in as a function — now() — and a test can hand it one that returns chosen instants. The worked code below exercises only the budget and pre-request deadline. The later illustrative fetcher also injects sleep so retry waits can be tested without real delays.

Read the code

A budget and pre-request deadline, checked before each proposed request. A per-call timeout belongs to the transport contract and is not exercised by this loop:

def new_run(max_requests, deadline_at, timeout=5, max_attempts=3):
    """Illustrative run settings and a counter for requests started."""
    return {"max_requests": max_requests, "deadline_at": deadline_at,
            "timeout": timeout, "max_attempts": max_attempts, "used": 0}


def may_request(run, now):
    """(allowed, reason). The reason names which limit stopped the run."""
    if run["used"] >= run["max_requests"]:
        return False, f"request budget spent after {run['used']} requests"
    if now >= run["deadline_at"]:
        return False, f"deadline passed at {now:.0f}s"
    return True, ""


def spend(run):
    run["used"] += 1
    return run["used"]


clock = iter([0, 10, 20, 30, 40])
run = new_run(max_requests=3, deadline_at=35)
for now in clock:
    allowed, reason = may_request(run, now)
    if not allowed:
        print("stop:", reason)
        break
    print("request", spend(run), "at", now)
print("used:", run["used"])

The budget is checked before the deadline as a reporting precedence when both have run out. That precedence identifies the first reported gate, not the root cause of a slow run. The worked loop never passes its timeout setting to a real client.

spend is separate from may_request so that the check has no side effect. A check that increments a counter cannot be called twice, and the first thing anyone does when debugging is call it twice.

The clock is an iterator here rather than a real clock, so this pre-request gate example is deterministic and finishes instantly.

Three limits with different jobs

Transport setting: each call

Timeout
Pass a value the chosen client understands
If it stalls
The client must raise its documented timeout error

The worked clock loop does not make a network call or enforce this setting.

Pre-request gate: whole run

Request budget
Refuse once the allowed request count is spent
Deadline
Refuse a new request at or after the configured time

The worked may_request checks budget first, then deadline. It cannot cancel a call already in flight.

Response: a different decision

Empty successful list
End paging normally; this page has zero records
Client error or invalid response
Report or propagate an error; do not call it empty

A successful no-work run and a failed request need different run records.

These are separate contracts, not a single timer. The worked code demonstrates only two pre-request gates; a transport must enforce the per-call timeout, and a scheduler or supervisor must bound total runtime if required.

The figure separates a timeout passed to an illustrative transport from the two checks this worked loop actually runs. It also separates a successful empty list from a client error. It does not show the worked clock’s answer.

Predict the output

Predict every line.

Check your prediction
request 1 at 0
request 2 at 10
request 3 at 20
stop: request budget spent after 3 requests
used: 3

The budget of three is spent before the clock reaches the deadline of 35, so the fourth iteration — at now = 30 — is refused by the budget. Reversing the checks in this worked run still reports the budget: 30 is less than 35. The order matters only when both gates are exhausted at a check; the modified example below creates that case.

Modify the code

Change deadline_at to 25 and run it again.

What changes, and why
request 1 at 0
request 2 at 10
request 3 at 20
stop: request budget spent after 3 requests
used: 3

Identical output. At now = 30, both the three-request budget and the new deadline of 25 have run out. Budget-first reports the budget; reversing those two checks in this modified run would report the deadline.

That is worth noticing rather than glossing over, because it is the answer to "which limit should I set carefully?" Both, and they catch different things — but a run that habitually stops on its budget never exercises its deadline, so the deadline is untested until the day the service slows down. To test the deadline alone, use a generous budget, as the exercise below does.

Debug the bug

An assistant was asked to "fetch the records, and retry if it fails". It produced this.

import time

def fetch_all(client):
    records = []
    page = 0
    while True:
        try:
            batch = client.get(page)
        except Exception:
            time.sleep(5)
            continue
        if not batch:
            break
        records.extend(batch)
        page += 1
    return records
What's actually wrong

This code can leave a scheduled job stuck or retrying indefinitely, and overlapping schedules can compound it.

  1. continue after the sleep retries forever. There is no attempt count, no budget, and no deadline. A service that is down does not make this loop fail; it makes it run until somebody notices, sleeping five seconds at a time, for as long as the machine stays up.
  2. No timeout is passed to client.get. If this client has no suitable default and the call stalls, the sleep(5) is never reached. Check the client’s actual timeout behavior and set an appropriate connect/read contract.
  3. except Exception catches too many client-call errors. It can retry an unexpected programming error raised inside client.get, hiding a defect as a service outage. It does not catch KeyboardInterrupt, which inherits from BaseException, and the later records.extend(batch) is outside this try.
  4. while True with no page cap. Even when every request succeeds, a service that keeps returning a non-empty page — because the pagination parameter is being ignored, which happens — pages forever.
  5. time.sleep is fixed real time. Tests can patch it, but injecting a sleep function makes retry waits fast, deterministic, and easier to assert.

An illustrative bounded fetcher. Assume client.get(page, timeout=...) accepts this timeout setting, raises TimeoutError on an elapsed call, and returns a list on success. Real client exception classes and timeout semantics differ:

def fetch_all(client, run, now, sleep):
    records, page, attempts = [], 0, 0
    while True:
        allowed, reason = may_request(run, now())
        if not allowed:
            return records, reason
        spend(run)
        try:
            batch = client.get(page, timeout=run["timeout"])
        except TimeoutError:
            attempts += 1
            if attempts >= run["max_attempts"]:
                return records, f"gave up after {attempts} attempts"
            retry_now = now()
            allowed, reason = may_request(run, retry_now)
            if not allowed:
                return records, reason
            remaining = run["deadline_at"] - retry_now
            if remaining <= 0:
                return records, "deadline passed before retry wait"
            sleep(min(2**attempts, remaining))
            continue
        if not isinstance(batch, list):
            return records, "bad response"
        if len(batch) == 0:
            return records, "complete"
        records.extend(batch)
        page += 1
        attempts = 0

Every proposed request is checked against the budget and deadline first and spends budget whether it succeeds or times out. The illustrative client receives a timeout setting, and only its assumed TimeoutError is retried. Before a retry wait, the code checks whether another request is allowed; it caps the requested wait to remaining time. Attempts are bounded and reset after a successful page. An empty list is a valid completion, while a non-list response gets a named refusal. The clock and sleep are injected for fast tests. Other client errors propagate rather than being mislabeled as empty results; pre-request checks still do not enforce a hard whole-job deadline.

Try it yourself

Write may_request, which decides whether a run may make another request. It is exercised against two supplied scenarios: one that runs out of budget and one that runs out of time. The reason must name which limit stopped it.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: work out your own three numbers.

For a job you would actually schedule, write down the per-request timeout, the whole-run request budget, and the deadline, and — this is the part that makes it useful — write one sentence per number saying what you would do if the job started hitting it every day.

What a good answer looks like

The deadline is usually the easiest to derive and the one people skip: it is some fraction of the gap between scheduled runs. A job that runs hourly and takes forty minutes at its deadline has no room for a slow day, so half the interval is a common starting point.

The budget is worth deriving from the data rather than guessed: expected records divided by page size, times a small allowance for retries. Deriving it means that when the job starts hitting the budget, the message is telling you the data grew — which is information, not noise.

The answer to "what would you do if it hit this every day" should differ per number. A budget hit every day calls for checking data growth, page count, and retries before changing the limit. A deadline hit every day calls for measuring service latency, waits, and local work before changing its value.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why is a per-request timeout more important for a scheduled job than for a script you are watching?
  2. What does a whole-run request budget catch that a retry count does not?
  3. What does a deadline catch that a budget cannot?
  4. A job reports "0 records, run complete" every day for a week and nobody notices anything wrong. Name two very different explanations.
Answers
  1. Nobody is there to stop it. A request with no timeout turns into a process that waits indefinitely, and when the schedule fires again the runs pile up on top of each other until the machine has nothing left.
  2. The interaction between loops. Twenty pages each retrying five times is a hundred requests and no individual retry count is exceeded. A budget covers every request the run makes, whatever the reason for it.
  3. Slowness. Fifty requests that each take forty seconds are within any sensible budget and take over half an hour, which a budget cannot see because it counts requests rather than time.
  4. Either there genuinely was nothing new — a service that returns zero records is not broken — or an error is being caught and reported as an empty result. The two look identical in that message, which is why the run record has to distinguish them.

Sign in to track your progress on this exercise.

Summary and next step

Before the first call: a timeout on every request, a request budget covering the whole run, a pre-request deadline before the next planned run, and a decision about what an empty result means. Check the budget and the deadline before each request, keep the check free of side effects, and return which limit stopped the run. Inject the clock and sleep for fast retry tests; separately bound any in-flight call and the job’s total runtime. Next: where the responses come from when there is no network — recorded exchanges, replayed through the same code path a real call would take.

learning.goultergroup.com

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