Skip to main content
Learning Center
Workflow Automation

Connect Services

Paging Under One Budget That Retries Also Spend

Walking a paged result set when the service sometimes asks you to wait, with a single request budget covering pages and retries alike and a clock the test controls so the waiting is instant and observable.

Lesson 12 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 collect every page of a result set while respecting a wait the service asks for, with pages and retries drawing on one shared request budget, and return a stop reason that says whether the collection is complete or was cut short.

Why it matters

Pagination and retries are each simple. Together their separate limits multiply, and the product is easy to miss.

A job pages through results with a cap of twenty pages. It allows up to five attempts per page. Both numbers look careful. The run could make up to a hundred requests if each of twenty pages succeeds only on its fifth attempt. Those separate caps do not limit the total, and that worst case would add load to a service already rate-limiting requests.

The second problem is the wait. Some rate-limit responses publish how long to wait before trying again. Ignoring that and retrying immediately can trigger another limit response; sleeping a fixed five seconds when the service said sixty is still too early. When the response publishes a duration, use it before an allowed retry.

A retry path tied directly to real sleep makes tests slow unless the delay is replaced or mocked. Inject a wait function and a test can record requested delays instantly and assert them exactly.

Concept

One budget, spent by everything. The run has a number of requests it may make. A page costs one. A retry costs one. A retry of a page you have already tried twice costs one more. There is no separate retry allowance, because a separate allowance is what lets the two limits multiply.

The budget is checked before each attempt and spent whether the attempt succeeds or fails. The caller counts every attempt it makes, even when no records come back. The service's own rate-limit accounting may differ; that does not increase this job's allowance.

Honour the wait the service publishes. When a response says how long to wait and another attempt is permitted, use that duration before retrying. Stop without waiting after the final allowed attempt. Two details:

  • The published value can be larger than you expect. Sixty seconds against a five-minute deadline is fine; six hundred seconds is the service telling you this run is not going to happen, and the right response is to stop with that reason rather than to sleep through the deadline.
  • When no wait is published, back off on your own schedule — doubling from a small base — and stop after a bounded number of attempts. Doubling with no bound is not a bound.

Inject the clock. Two functions, passed in: one that reports the current time and one that waits. In production they are the real ones. In a test they are a counter and a recorder, so a run that "waits" ninety seconds finishes instantly and the test can assert that it waited ninety.

def make_recording_clock():
    """A clock that records waits instead of performing them."""
    state = {"now": 0.0, "waits": []}
    def now():
        return state["now"]
    def wait(seconds):
        state["waits"].append(seconds)
        state["now"] += seconds
    return now, wait, state

Recording the waits rather than discarding them is what makes "it backed off correctly" an assertion instead of an opinion. The worked sample below injects only wait; it does not implement this clock's deadline check or a fallback schedule for a response without a published wait.

Return a reason for each handled stop. This scripted page loop has three ordinary endings and they are not interchangeable:

  • complete — the service said there are no more pages.
  • budget spent — the run stopped early and the result is partial.
  • gave up — a page failed more times than allowed.

Only the first means the records are all of them. Transport exceptions are outside this scripted example and still propagate; a production client needs an explicit exception or error-result policy. A function that returns a list with no reason gives its caller no way to tell a complete result from a truncated one, and the caller may write it into a report as though it were complete. That report will be wrong in the direction that matters: it will under-report, quietly, on the busiest day.

Reset the attempt counter after a success. A long run that hits one transient failure on page 3 and another on page 17 has not failed twice in a row. Counting attempts per page rather than per run is what stops an otherwise healthy long run from being killed by unrelated blips.

Read the code

SCRIPT = {
    1: [{"wait": 2}, {"records": ["a", "b"], "next": 2}],
    2: [{"records": ["c"], "next": None}],
}


def make_transport(script):
    """Returns the next scripted response for a page, in order."""
    remaining = {page: list(responses) for page, responses in script.items()}
    def transport(page):
        if not remaining.get(page):
            raise LookupError(f"no scripted response left for page {page}")
        return remaining[page].pop(0)
    return transport


def collect(transport, budget, wait, max_attempts=3):
    """Page through everything, or stop with a reason. Returns (records, reason, used, waited)."""
    records, page, used, waited, attempts = [], 1, 0, 0, 0
    while True:
        if used >= budget:
            return records, "budget spent", used, waited
        used += 1
        response = transport(page)
        if "wait" in response:
            attempts += 1
            if attempts >= max_attempts:
                return records, "gave up", used, waited
            if used >= budget:
                return records, "budget spent", used, waited
            wait(response["wait"])
            waited += response["wait"]
            continue
        records.extend(response["records"])
        if response["next"] is None:
            return records, "complete", used, waited
        page = response["next"]
        attempts = 0


records, reason, used, waited = collect(make_transport(SCRIPT), budget=10, wait=lambda s: None)
print("records:", ",".join(records))
print("reason:", reason)
print("used:", used)
print("waited:", waited)

used += 1 sits before the request rather than after it, so the caller spends its allowance on every attempt. If this scripted transport raises, the exception propagates rather than returning the counter or a reason; production code needs an error policy. A budget that only counts successes is not a budget.

attempts = 0 after a successful page is the per-page reset. continue retries the same page, because page was not reassigned. If a wait response used the final request allowance, the function stops without waiting for a retry it cannot make.

wait is a parameter, and the caller here passes a function that does nothing, so this simulation finishes instantly while showing the duration passed to that function. A production wait adapter must actually delay.

One counter for page requests and retries

Attempt a page

Request counter
Spend one before the transport call
Requested wait
No injected wait at this step

A success can advance to the next page. A rate-limit response keeps the current page for an allowed retry.

Wait if a retry is allowed

Request counter
Waiting spends no additional request
Requested wait
Pass the published duration to the injected wait

If the per-page attempt cap has been reached, stop without waiting. The worked code does not check a deadline.

Try again or stop

Request counter
A retry spends one from the same budget
Page
Retry the same page; a later page uses that same budget

A depleted request budget yields a partial result with a stop reason.

This is a generic path, not the worked script's measured output. The worked collector enforces a shared request budget and a per-page attempt cap. A separate deadline check is an optional extension in the challenge.

Predict the output

Predict every line.

Check your prediction
records: a,b,c
reason: complete
used: 3
waited: 2

Three requests: page 1 answered with a wait, page 1 again returning two records, then page 2 returning the third and no next page. The value 2 was passed to the injected wait and added to waited, which tracks what the service asked for rather than elapsed time. This simulation does not sleep.

used is 3 and not 2. The rate-limited attempt cost a request, which is the whole reason retries and pages share one budget.

Modify the code

Call it with budget=2.

What changes, and why
records: a,b
reason: budget spent
used: 2
waited: 2

Two of the three records. The list looks like a perfectly good result and it is missing a record.

This is the case the reason exists for. A caller that ignores reason and writes records into a report publishes a number that is wrong and looks right — and it will be wrong on exactly the day the data was large enough to need the third page, which is the day somebody is looking at the report.

Notice also which limit stopped it. The budget was spent by two requests, one of which was consumed by a rate-limit response that returned no data at all. A budget derived from "we expect three pages, so allow three requests" would fail on the first day the service asked anyone to wait.

Debug the bug

An assistant was asked to "get all the pages, and handle rate limiting". It produced this.

import time

def get_all(client, max_pages=20):
    records = []
    for page in range(1, max_pages + 1):
        for attempt in range(5):
            response = client.get(page)
            if response.status_code == 429:
                time.sleep(2**attempt)
                continue
            break
        records.extend(response.json()["records"])
        if not response.json()["has_more"]:
            break
    return records
What's actually wrong
  1. Twenty pages times five attempts allows up to a hundred requests if each page eventually succeeds on its fifth attempt. Nothing in the code states that total. Both limits look careful in isolation; their product is the possible maximum.
  2. It ignores the wait the service published. 2**attempt gives 1, 2, 4, 8, 16 seconds regardless of what the response said. If the service asked for sixty, every one of those attempts is too early, and each early attempt is itself a request.
  3. When all five attempts are rate-limited, the loop exits normally and the code proceeds to response.json()["records"] on the 429 response. Depending on the service that is a KeyError, an empty list, or an error document parsed as data. The one thing it is not is a report that the page failed.
  4. response.json() is called twice. Whether that reparses, returns cached data, or fails depends on the client. Parse once into a variable so the result and failure point are explicit.
  5. Nothing distinguishes finishing from stopping. Hitting max_pages returns silently with a partial list, identical in shape to a complete one.
  6. time.sleep is called directly, so an unmodified test of the retry path really waits. Injecting or mocking the wait makes the path fast and assertable.

The version that can be reasoned about:

def get_all(client, budget, wait, max_attempts=3):
    records, page, used, attempts = [], 1, 0, 0
    while True:
        if used >= budget:
            return records, "budget spent"
        used += 1
        response = client.get(page)
        if response.rate_limited:
            attempts += 1
            if attempts >= max_attempts:
                return records, "gave up after repeated rate limiting"
            if used >= budget:
                return records, "budget spent"
            wait(response.retry_after)
            continue
        records.extend(response.records)
        if not response.has_more:
            return records, "complete"
        page = response.next_page
        attempts = 0

One budget covering everything, a published wait before an allowed retry, a bounded attempt count that resets after a success, an injected wait so the path is testable, and a reason for each handled stop. A production client also needs a policy for transport exceptions.

Try it yourself

Write collect, which pages through a scripted service. Pages and retries share one budget, a published wait is passed to the injected wait function before an allowed retry, and each handled stop returns a reason. It is run three times against the same script: with enough budget, with two requests, and with one. The one-request run must not wait when it cannot retry.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: decide when a published wait is too long to honour.

Add a deadline_at to collect and a rule: if the wait the service asks for would take the run past its deadline, stop immediately with a reason saying so, rather than waiting and then failing. Then decide what the job should do next time it runs.

What a good answer looks like

Stopping with "asked to wait 600s, which exceeds the remaining 240s" is worth more than any amount of retrying, because it is a sentence that explains the whole situation to whoever reads it.

The harder half is what happens next. If the job re-runs on its schedule and the service is still limiting, every run does the same thing and stops, which is fine and self-correcting. If instead the job responds by retrying sooner, it makes the situation worse. The correct behaviour when a service says "wait longer than you have" is almost always to do nothing until the next scheduled run, and to make sure the run record says why — which is what the next module builds.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why should pages and retries share one budget rather than having their own limits?
  2. Why does a rate-limited attempt that returns no data still cost a request?
  3. Why must a page loop return a reason as well as the records?
  4. Why is a fixed doubling backoff worse than the wait the service publishes?
Answers
  1. Because independent limits multiply. Twenty pages with up to five attempts each permits a hundred requests if every page succeeds only on its fifth attempt, even though nobody wrote down that total.
  2. The caller made an outgoing attempt even though it received no records. This job must count that attempt in its own budget, whatever accounting the service uses. A budget that only counts successful requests undercounts exactly when the run is behaving worst.
  3. Otherwise a truncated result is indistinguishable from a complete one, and a caller will write it into a report as though it were everything. That under-reports quietly, on the day the data was largest.
  4. Because the service has already answered the question. A computed backoff is a guess, and when it is shorter than the published value every attempt is too early, each one costs a request, and the job is more likely to be blocked than if it had waited once.

Sign in to track your progress on this exercise.

Summary and next step

Give the whole run one request budget, spend it before every attempt including the ones that fail, and honour the wait the service publishes rather than one you calculated. Bound the attempts per page and reset the count after a success. Inject the clock so the waiting path is tested rather than skipped. Return a reason from every handled stop, and specify how a production client reports transport exceptions, because a partial result that looks complete is worse than no result. The next module is about the run itself: what it writes down, how it recovers from stopping halfway, and how anyone notices when it stops running at all.

learning.goultergroup.com

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