Aggregate paginated JSON from a local HTTP server
Problem statement
A service catalog exposes GET /api/services?page=N. Each response is JSON with a services list (3 per page) and a next field holding the relative URL of the next page, or null on the last page. Write a script that walks every page with urllib.request and prints, per team, the total number of services and a count per status.
To keep the exercise offline and repeatable, the script starts its own server with http.server on 127.0.0.1, port 0 (the OS picks a free port), in a background thread. The server holds this data:
services (server side)
[ {"name": "auth", "team": "identity", "status": "healthy"}, {"name": "billing", "team": "payments", "status": "degraded"}, {"name": "checkout", "team": "payments", "status": "healthy"}, {"name": "ledger", "team": "payments", "status": "down"}, {"name": "login-ui", "team": "identity", "status": "healthy"}, {"name": "search", "team": "discovery"}, {"name": "recommend", "team": "discovery", "status": "healthy"}]Requirements:
- Follow
nextuntil it isnull, resolving it against the current URL withurljoin. - Set a timeout on every request and check that the response
Content-Typeis JSON before parsing it. - A service with no
statuscounts asunknown; a service with noteamgoes underunowned. - Print teams in alphabetical order, statuses alphabetically within a team, then a
needs attention:line listing teams that have anythingdownorunknown. - Finally, request
/api/nopeand show that the 404 is reported cleanly, not as a traceback. - Shut the server down at the end, even if something failed.
Examples
Example 1
Input: `python solution.py` (starts the local server, fetches 3 pages)
Output: discovery total=2 healthy=1, unknown=1
identity total=2 healthy=2
payments total=3 degraded=1, down=1, healthy=1
needs attention: discovery, payments
bad path -> HTTP 404
Explanation: search has no status, so discovery shows unknown=1 and needs attention. payments has ledger down.
Hints
Approach
Optimal
Server. A BaseHTTPRequestHandler subclass slices the list by page number and returns JSON with an explicit Content-Type and Content-Length. Overriding log_message keeps request logs out of the output. The server runs serve_forever in a daemon thread, and the finally block calls shutdown() and server_close() so the port is released even if the client code raises.
Fetching. get_json sends an Accept header, always passes timeout, and uses with urlopen(...) so the connection is closed. It checks the Content-Type before calling json.load: a proxy error page or a login redirect often returns HTML with status 200, and json.load on HTML gives a confusing JSONDecodeError. fetch_services is a generator: it yields services page by page and follows next with urljoin(url, nxt), which handles both relative and absolute links. A page cap protects against a server that never says null.
Aggregating. summarize builds team -> Counter(status) in one pass, using .get() defaults for missing fields instead of letting one incomplete record crash the report. Printing sorts both levels, so the output does not depend on the order the API returned things in.
Errors. HTTPError is caught separately to report the status code. Anything else from the network (URLError, including connection refused and timeouts) or a non-JSON response ends up in the outer except with one readable line.
n is the number of services, t the number of teams and s the number of distinct statuses.
O(n)Space O(t * s)import jsonimport threadingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom urllib.parse import parse_qs, urlparse SERVICES = [ {"name": "auth", "team": "identity", "status": "healthy"}, {"name": "billing", "team": "payments", "status": "degraded"}, {"name": "checkout", "team": "payments", "status": "healthy"}, {"name": "ledger", "team": "payments", "status": "down"}, {"name": "login-ui", "team": "identity", "status": "healthy"}, {"name": "search", "team": "discovery"}, {"name": "recommend", "team": "discovery", "status": "healthy"},]PER_PAGE = 3 class Handler(BaseHTTPRequestHandler): # Local stand-in for the real service catalog: GET /api/services?page=N def do_GET(self): url = urlparse(self.path) if url.path != "/api/services": self.send_error(404) return page = int(parse_qs(url.query).get("page", ["1"])[0]) start = (page - 1) * PER_PAGE chunk = SERVICES[start:start + PER_PAGE] more = start + PER_PAGE < len(SERVICES) body = json.dumps({ "services": chunk, "next": f"/api/services?page={page + 1}" if more else None, }).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass # keep the demo output clean def start_server(): server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) # port 0 = any free port threading.Thread(target=server.serve_forever, daemon=True).start() return server import jsonfrom collections import Counter, defaultdictfrom urllib.error import HTTPError, URLErrorfrom urllib.parse import urljoinfrom urllib.request import Request, urlopen def get_json(url, timeout=5): req = Request(url, headers={"Accept": "application/json"}) with urlopen(req, timeout=timeout) as resp: ctype = resp.headers.get("Content-Type", "") if not ctype.startswith("application/json"): raise ValueError(f"{url}: expected JSON, got {ctype!r}") return json.load(resp) def fetch_services(base_url, first_path="/api/services", max_pages=100): url = urljoin(base_url, first_path) for _ in range(max_pages): page = get_json(url) yield from page.get("services", []) nxt = page.get("next") if not nxt: return url = urljoin(url, nxt) # works for relative and absolute links raise RuntimeError("too many pages") def summarize(services): by_team = defaultdict(Counter) for svc in services: team = svc.get("team") or "unowned" by_team[team][svc.get("status", "unknown")] += 1 return by_team server = start_server()base = f"http://127.0.0.1:{server.server_address[1]}"try: summary = summarize(fetch_services(base)) for team in sorted(summary): counts = summary[team] total = sum(counts.values()) detail = ", ".join(f"{s}={n}" for s, n in sorted(counts.items())) print(f"{team:<10} total={total} {detail}") bad = sorted(t for t, c in summary.items() if c["down"] or c["unknown"]) print("needs attention:", ", ".join(bad)) try: get_json(base + "/api/nope") except HTTPError as e: print(f"bad path -> HTTP {e.code}")except (URLError, ValueError) as e: print("error:", e)finally: server.shutdown() server.server_close()Follow-up questions
- Fetch the pages in parallel when the API returns a total page count up front.
- The API starts requiring a bearer token. Where does it go, and how do you keep it out of logs?
- Make the summary machine-readable with a
--jsonflag so another tool can consume it.
Frequently asked questions
Status pages, service catalogs, CMDBs and monitoring tools all expose paginated JSON APIs, and small reporting scripts over them are everyday work. Interviewers use this to check that you handle pagination, timeouts, HTTP errors and messy records, not just the happy path.
It is not in the standard library. Many interview environments and minimal containers only have plain Python, and urllib.request covers GET requests, headers, timeouts and JSON with a few more lines. If requests is allowed, the structure of the solution stays the same.
A local server exercises the real HTTP path: headers, status codes, HTTPError and connection handling. It is still fully offline and deterministic, because it binds to 127.0.0.1 and serves fixed data.