Skip to main content
Learning Center
Python Programming

Module 14: The Opportunity Review Assistant

Capstone Phase 8 to 11: The API Contract, the Interface, One Reviewed Change, and the Handover

Turning a written API contract into a transport interface and a tested failure policy, exposing read-only results locally, making one bounded reviewed change, and writing the threat review, runbook, and retrospective that finish the project.

Lesson 46 of 46 in the recommended order · About 30 min (estimate)

On this page

Outcome

By the end of this lesson you can turn a written API contract into a transport interface and a failure policy your tests drive from stored cases, expose the results through a read-only local interface, make one bounded change you have reviewed properly, and write the three documents that hand the project to somebody else.

Why it matters

Four phases remain, and they are what turn a working pipeline into a finished project.

Phase 8 is where the client stops being a thing that reads your fixtures and becomes a thing that implements a published contract. You read the contract, write down what it commits to, and turn its error table, its paging rule, and its limits into decisions your tests drive through every branch. The contract lives in fixtures/api-contract.md and the responses live beside it, so all of that happens at full speed with nothing to configure — which is the only way to reach a timeout, a rate limit, and a truncated body reliably in the first place.

Phases 9 to 11 are the difference between code that works on your machine and a project someone else can run. The interface makes the results reachable, the reviewed change proves you can accept work safely, and the three documents mean the project survives you forgetting how it works.

Concept

Opportunity Review Assistant: the final four phases

Continue from the stored pipeline: Use the project scope, field policies, stored results and tests from earlier phases.

These are deliverables to build and check, not completed results or a solution.

Phase 8 · Contract and transport

Read
Summarise inputs, envelope, paging, errors and authentication from the fictional contract.
Build
Inject a fixture transport; test bounded paging, timeout and retry decisions.

No real provider, credential or outbound request is required.

Phase 9 · Local read interface

Expose
Shortlist, summary and health read paths.
Guard
Validate parameters before queries; return named errors.

No route writes. Loopback binding alone is not authentication.

Phase 10 · Reviewed change

Define
One small task with a file boundary and executable acceptance criteria.
Review
Inspect diff, tests and error handling, then explain the change.

A proposed change is not accepted until the evidence is reviewed.

Phase 11 · Handover

Threat and privacy
Record actual trust, data, logging and failure boundaries.
Runbook
Cover setup, normal, dry and failed runs, recovery and logs.
Retrospective
Name what worked, what failed and one improvement.

These documents must be written for this project; the map does not supply their answers.

Phases 8–11 finish the project by testing the fictional API contract, exposing stored results for local reading, reviewing one bounded change and writing an operational handover. The diagram gives a route through the work; it does not replace the contract analysis, code, tests or documents.

Phase 8: the API contract and the transport interface. Open fixtures/api-contract.md and write a one-page summary of it before writing any code. Five things, in this order:

  • Inputs. Which parameters exist, which are required, what happens to values out of range, and what happens to a parameter the service does not recognise. This one ignores unknown parameters, so a misspelled filter silently widens your result set.
  • Response shape. The envelope, the record fields, and which of them may be absent, null, or a type you did not expect. estimatedValue is a string. placeOfPerformance can be missing entirely.
  • Pagination. Offset and limit, ending on a page shorter than the limit. totalRecords is a hint to check afterwards, never the loop's exit condition.
  • Errors. Which statuses exist, what each means, and which are worth retrying. 429, 500, and 503 are; 400, 401, 403, and 404 are not, because repeating a request the provider already refused does not change the answer.
  • Authentication. What the contract commits to, and nothing more.

Then build against it. The client depends on a transport interface, not on an HTTP library — nothing in the client module imports one — and the fixture transport serves page 1 for offset 0 and the short page 2 for offset 8. Four bounded values are configuration, and each is asserted by a test: a timeout, a maximum number of attempts per request, a backoff delay sequence, and a maximum page count. fixtures/transport-failures.json holds eleven stored outcomes, each paired with the decision a correct client makes, so the rate limit, the server fault, the HTML error body, the two malformed envelopes, the truncated body, the timeout, and the refused connection are all reachable in a test suite that finishes in under a second.

A note on that last bullet, because it is the one people get wrong from memory. How a credential is transmitted is decided by the provider and stated in its documentation. Some providers read a request header, some read a query parameter, some require a signed request. There is no general rule to apply and nothing to infer. This phase reads that section of the contract and summarises it; it does not implement it. You are not asked to obtain, configure, or hold a credential at any point in this course, and the project you finish is complete without one. Connecting a client to a real provider is outside the course; if you ever do it independently, that provider's current official documentation and terms govern it.

Phase 9: the read-only interface. Routes for the shortlist, its summary, and a health check. Parameters validated for presence, type, and range before any query runs. Error bodies carrying a stable code and a request identifier and no internal detail. Bound to the loopback address, with the documentation stating plainly that this stops other machines and is not authentication. No route writes anything.

Phase 10: one bounded change. Pick something small in a well-tested layer, the rules engine is ideal. Write the task with a goal, a file boundary, executable acceptance criteria, constraints, and the evidence required. Review the diff in Module 13's order: file list, deletions, conditions, defaults, error handling, dependencies, then everything else. Then write two paragraphs in plain language saying what changed and why it is correct. That explanation is the deliverable; a change you cannot explain does not go in.

Phase 11: three documents.

The threat and privacy review covers the surface this project actually has: trust in the input data, SQL injection, unbounded work, data loss on re-run, what the logs contain, and what a modified fixture file could do. It should also say plainly that the project holds no credential and makes no outbound request, because the most useful line in a threat model is often the one ruling something out. The privacy section states what the project stores and what it never stores.

The runbook covers setup, a normal run, a dry run, a failed run, recovery, and where the logs are. Its acceptance test is that someone who has not seen the project can complete a full run from it alone.

The retrospective names one thing that worked, one that did not, and one thing to do differently. Include the defect you seeded in phase 7 and which test caught it.

Read the code

import json

CONTRACT = {
    "page_size": 8,
    "retryable_statuses": (429, 500, 502, 503, 504),
    "max_attempts": 3,
    "backoff_seconds": (1, 2, 4),
    "timeout_seconds": 10,
    "max_pages": 5,
}

PAGES = {
    0: '{"totalRecords": 12, "limit": 8, "offset": 0, "results": [1,2,3,4,5,6,7,8]}',
    8: '{"totalRecords": 12, "limit": 8, "offset": 8, "results": [9,10,11,12]}',
}


def fixture_transport(params):
    """Serves a stored page for the offset it is asked for. Raises for an
    offset the contract would never produce, so a paging mistake surfaces."""
    offset = params["offset"]
    if offset not in PAGES:
        raise LookupError(f"no stored page at offset {offset}")
    return {"status": 200, "text": PAGES[offset]}


def read_page(response, offset):
    """Return (validated page, error), checking the paging envelope."""
    if response["status"] != 200:
        return None, f"status {response['status']} at offset {offset}"
    try:
        body = json.loads(response["text"])
    except json.JSONDecodeError as problem:
        return None, f"unreadable body at offset {offset}: {problem.msg}"
    if not isinstance(body, dict):
        return None, f"malformed envelope at offset {offset}: not an object"
    if not isinstance(body.get("results"), list):
        return None, f"malformed envelope at offset {offset}: results is not a list"
    limit = body.get("limit")
    if type(limit) is not int or limit < 1:
        return None, f"malformed envelope at offset {offset}: invalid limit"
    if type(body.get("offset")) is not int or body["offset"] != offset:
        return None, f"malformed envelope at offset {offset}: wrong offset"
    total = body.get("totalRecords")
    if type(total) is not int or total < 0:
        return None, f"malformed envelope at offset {offset}: invalid totalRecords"
    if len(body["results"]) > limit:
        return None, f"malformed envelope at offset {offset}: overfull results"
    return body, None


def collect(transport, contract):
    """Page by the applied response limit until a short page, within a cap."""
    records, offset = [], 0
    requested_limit = contract["page_size"]
    for _ in range(contract["max_pages"]):
        page, error = read_page(
            transport({"offset": offset, "limit": requested_limit}), offset
        )
        if error is not None:
            return None, error
        records.extend(page["results"])
        if len(page["results"]) < page["limit"]:
            return records, None
        offset += page["limit"]
        requested_limit = page["limit"]
    return None, f"stopped at the {contract['max_pages']}-page cap"


records, error = collect(fixture_transport, CONTRACT)
print("records:", len(records), "error:", error)
print("requests:", len(PAGES))
print(read_page({"status": 200, "text": '{"totalRecords": 12}'}, 0)[1])
print(read_page({"status": 503, "text": "<html>503</html>"}, 8)[1])

collect and read_page demonstrate paging and envelope validation. The supplied fixture transport is local; a different injected transport could make a network request. A complete Phase 8 client must also implement and test the stored timeout, retry, backoff, and fail-fast cases. read_page validates the paging fields before trusting them, which keeps a 200 carrying no results key from being reported as zero records. collect advances by the response's applied limit, not just the requested size, so a clamped full page does not look like the last page. A silent wrong answer is worse than a reported error.

fixture_transport raises for an unknown offset rather than returning an empty page. A fixture that quietly answers "nothing here" for an offset your paging arithmetic invented would hide the bug it exists to catch.

The CONTRACT dictionary matters as much as the code. The timeout, the attempt limit, the backoff sequence, and the page cap are four bounded settings from the supplied client policy. This worked snippet uses the page size and page cap; the complete client and its tests must use and assert the timeout, attempt limit, and backoff settings too. Its retryable list also includes 502 and 504 as client policy beyond the fictional contract table, which explicitly lists 429, 500, and 503.

Predict the output

Predict the four printed lines.

Check your prediction
records: 12 error: None
requests: 2
malformed envelope at offset 0: results is not a list
status 503 at offset 8

Twelve records in exactly two requests: the first page is full at eight, so paging continues; the second returns four, which is short, so the loop ends there. That matches totalRecords, which is how you check afterwards rather than how you decide when to stop.

The third line is the malformed-envelope case. A missing results key gives None from body.get, which is not a list, so the error names the field rather than reporting an empty result.

The fourth is the HTML error body from the contract's error table. The status is checked before anything is parsed, so a body that is not JSON never becomes a confusing JSONDecodeError several frames away.

Modify the code

Change read_page to parse the body before checking the status, so the json.loads call happens first. Predict what the fourth printed line becomes, and say why the original order is right.

What changes, and why

The fourth line becomes an unreadable-body message about offset 8 instead of a status message, because <html>503</html> reaches json.loads and fails there.

The diagnosis is now wrong in a way that costs somebody an afternoon. The real event is that the service returned 503, which the contract lists as retryable: a temporary fault worth waiting out. The new message says the body could not be read, which sounds like a corrupt response or a client bug, and it hides the one piece of information that decides what happens next.

The general rule: check the cheapest, most authoritative signal first. The status line tells you what kind of response this is; the body only means anything once the status says it should be there.

Debug the bug

A submitted capstone includes this in its runbook, and this in the client.

A failed run. If the run reports errors, re-run it; most errors are temporary. If it still fails, set max_pages to 0 to remove the page limit and set the timeout to None so slow responses have time to finish. Logs are in run.log; attach the whole file when reporting a problem.

for attempt in range(10):
    response = transport(params)
    if response["status"] == 200:
        break
    logging.warning("attempt %s failed: %s", attempt, response)
return read_page(response, offset)[0] or []
What is wrong with this handover

Five faults, and the last one is what keeps the other four hidden long enough to matter.

"Re-run it; most errors are temporary." Some are. A 400 or a 401 is not, and re-running one repeats a request that was already refused. The runbook should send the reader to the contract's error table, which says which statuses are worth retrying and which are not.

Removing the page limit and the timeout as troubleshooting advice. Both limits exist because the alternative is unbounded work. Advice that removes a safety bound in response to a symptom belongs nowhere, least of all in a document people follow while something is already going wrong.

The retry loop ignores the contract entirely. Ten attempts, no backoff, no check of whether the status is retryable at all. Against a 429 this is the behaviour that caused the rate limit, applied harder.

The whole response is logged on every failed attempt. Nobody wrote "log everything"; a container was passed to the logger and its contents came along, which is the mechanism from Module 10's configuration example. Here it writes the full body of every failure into run.log, and in a project whose responses carry real data that is the leak.

or [] converts every failure into success. read_page returns None with an error, and or [] discards both. A run that exhausted its attempts against a 503 produces an empty shortlist, no exception, and an exit code of zero. Combined with "attach the whole file", the operator then sends a log full of response bodies to explain a report that was silently empty.

This corrected status-response branch follows the attempt cap and distinguishes the contract's 429 Retry-After delay from server-fault backoff. A complete client must also handle the transport's timeout and raised-failure cases and validate the delay before sleeping:

for attempt in range(1, contract["max_attempts"] + 1):
    response = transport(params)
    if response["status"] == 200:
        return read_page(response, offset)
    if response["status"] not in contract["retryable_statuses"]:
        return None, f"status {response['status']} at offset {offset}: not retryable"
    logging.warning("attempt %s: status %s", attempt, response["status"])
    if attempt < contract["max_attempts"]:
        if response["status"] == 429:
            delay = response.get("retryAfterSeconds")
            if type(delay) is not int or delay < 0:
                return None, f"missing usable Retry-After at offset {offset}"
        else:
            delay = contract["backoff_seconds"][attempt - 1]
        sleep(delay)
return None, f"gave up after {contract['max_attempts']} attempts at offset {offset}"

Every shown return path yields a pair, the log line carries a status rather than an object, and running out of attempts is reported as failure rather than an empty list. The retryAfterSeconds field is the stored scenario's parsed Retry-After value; the exercise has no live header or network call.

Try it yourself

Complete the retry decision. It reads a status, an attempt number, and a parsed Retry-After value when the status is 429. It returns what to do and how long to wait; it waits for nothing and requests nothing.

Loading this exercise…

Practical challenge (optional)

Optional, and it finishes the capstone. Complete phases 9, 10, and 11: build the read-only interface with its three routes and validated parameters, make one bounded change to the rules layer with acceptance criteria written first and the diff reviewed in order, and write the threat review, the runbook, and the retrospective. Then perform the runbook's own acceptance test: give it to someone who has not seen the project and watch them complete a full run without asking you anything. Every question they have to ask is a line the runbook is missing.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What does reading the API contract tell you before you write any client code?
  2. Why is a 400 handled differently from a 503?
  3. What is the runbook's acceptance test?
  4. Name three things the threat and privacy review must cover.
Answers
  1. Its inputs and how it treats unrecognised ones, the response envelope and which fields may be absent or a surprising type, the rule that ends paging, the error statuses and which are worth retrying, and what it commits to about authentication.
  2. Because the contract says so, and because they mean different things. A 503 is a temporary fault, so waiting and retrying is reasonable. A 400 means the request itself is wrong; repeating it repeats a refusal, wastes the rate limit, and delays the fix.
  3. Someone who has not seen the project completes a full run from the runbook alone, without asking a question. Every question they ask is a missing line.
  4. Any three of: trust in the input data, SQL injection, unbounded work, data loss on re-run, what the logs contain, and what the project stores versus never stores.

Sign in to track your progress on this exercise.

Summary and next step

The contract is read before the client is written and its limits become four values a test can assert, the interface stays read-only and locally bound, one bounded change is reviewed in a fixed order and explained in plain language, and three short documents hand the project on. That completes the Opportunity Review Assistant and Python Foundations: you can now read, predict, modify, test, and debug Python, and direct and review work produced with AI. Return to any lesson at any time; the course map is open from the first day to the last.

learning.goultergroup.com

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