Skip to main content
APPLIED PYTHON / IT AUTOMATION / LESSON 04

REST APIs & Authentication.

Connect the Python reporting pipeline to HTTP without confusing a successful connection with a valid result. Work through identity, authorization, pagination, timeouts, rate limits and safe response handling.

Intermediate90–120 minutes plus optional local loopback demoInteractive API explorer8 Python challengesReal localhost HTTP demo
00

What you'll learn

The first three Applied Python lessons established a local environment, a configurable command-line tool and a validated file pipeline. This lesson adds a new boundary: remote services can be slow, reject permissions, send only part of a dataset or return an unexpected response. You'll learn to make those conditions visible instead of publishing an inaccurate report.

  • Explain HTTP request methods, resource URLs, headers, status codes and JSON response contracts.
  • Distinguish 200 from 204 and authentication failures from permission failures.
  • Construct properly encoded URL query strings and enforce an approved HTTPS origin for real remote clients.
  • Describe API keys, OAuth access tokens, least-privilege delegated versus application authorization, and secret redaction.
  • Separate transport timeouts, HTTP errors, JSON decode errors and malformed response schemas.
  • Collect bounded, validated paginated records while rejecting repeated or unapproved next links.
  • Interpret 429 throttling and Retry-After guidance using safe, bounded retry policies for read-only requests.
  • Connect a fake response provider or optional localhost HTTP mock to deterministic offline inventory reporting.
Important execution boundary: The explorer below only simulates HTTP responses. The Python workbench executes real Python against injected fictional responses, with no outgoing network requests or credential exchange. The optional local demo uses an actual HTTP server bound exclusively to 127.0.0.1 on your computer. It never accesses a cloud tenant and needs no API key.

Prerequisites: Complete Filesystem & Structured Data Pipelines and Command-Line Tools & Configuration, or be comfortable with functions, dictionaries, JSON, explicit argument lists and error handling. This lesson is about writing trustworthy read-only clients, not acquiring permissions for a real production API.

PROGRAMS TALK OVER HTTP
01

Trace a request from method to response

Your CLI can now read and validate local files. Remote systems expose APIs so a program can request information instead of opening another application's data files. An HTTP request specifies a method, URL, headers and sometimes a body. A response supplies a status, headers and optional response body. REST APIs commonly represent resources using URLs and JSON, but each provider defines its own contract. A successful connection is not evidence that you requested the right resource or received the expected data.

GET is the first safe operation

Start with a read-only GET for fictional devices. Other common methods include POST to create, PATCH to modify, PUT to replace and DELETE to remove; method semantics depend on the API contract and permissions.

Resource paths describe targets

A path such as /v1/devices names a collection. A query such as ?site=HQ filters it; the server determines whether that filter is supported and how the response is paginated.

Headers carry protocol metadata

Accept: application/json expresses the content your client expects; Content-Type describes the format of any request body. Authorization is security-sensitive and must not be printed or placed in a URL.

PYTHON / PREDICT AND EXPLAINRead before you run
from urllib.parse import urlsplit
url='https://api.example.invalid/v1/devices?site=HQ'
parts=urlsplit(url)
print(parts.scheme, parts.path, parts.query)
If the server returns HTTP 200, can you conclude the response contains a usable inventory object? Reveal the explanation after predicting.

No. Status 200 is an HTTP success response, not schema validation. The body might be an array, a different object, an HTML error page or another unexpected shape. Check its content type, decode the body and validate required fields.

Remember: Keep request construction, transport, HTTP status and response validation separate. A successful HTTP exchange is only one stage of a trustworthy API workflow.
STATUS CODES ARE BRANCHES
02

Distinguish empty responses, errors and permission failures

HTTP response status changes what your client should do next. A 200 OK response may contain JSON, whereas 204 No Content indicates a successful response without a body, so calling json.loads('') is incorrect. Authentication failures (401) differ from insufficient permission (403). A 404 may indicate a missing resource or an endpoint you cannot see; 429 signals throttling and a 5xx response means the server couldn't complete the request. Don't classify every non-200 response as a network outage, and don't classify every 200 response as a valid application result.

401 versus 403

401 commonly means credentials are missing, expired or invalid and may include an authentication challenge. 403 means the server understood but refuses the request; signing in again is not a reliable fix for missing scope or permission.

429 and 503 aren't the same

429 Too Many Requests indicates rate limiting, sometimes with Retry-After. 503 Service Unavailable indicates a temporary service problem; retry policy depends on request semantics, server guidance and an explicit bound.

204 has no JSON body to parse

For a legitimate 204, handle a no-content success as its own branch. Don't interpret an absent body as a list of zero devices or as a malformed JSON export.

PYTHON / PREDICT AND EXPLAINRead before you run
def status_kind(code):
    if code == 204: return 'no-content'
    if code == 401: return 'authentication'
    if code == 403: return 'permission'
    if code == 429: return 'throttled'
    return 'success' if 200 <= code < 300 else 'error'
print(status_kind(401))
Why shouldn't a caller automatically reauthenticate after receiving 403? Reveal the explanation after predicting.

A 403 response usually means the operation is not permitted even if the client has identified itself. Repeating login attempts won't grant missing API scopes or roles. Review the documented authorization requirements with an approved administrator.

INTERACTIVE API REQUEST EXPLORER · FICTIONAL RESPONSE MODEL

What would your Python client do next?

Change the HTTP scenario, requested site and page budget. Inspect how the same read-only request behaves when authentication, permissions, JSON shape, throttling or pagination fail. No API call is made, and there is nowhere to enter real credentials.

STAGE 01 / REQUEST

Choose the method, resource path and response scenario

{
  "method": "GET",
  "url": "https://api.example.invalid/v1/devices?site=HQ",
  "headers": {
    "Accept": "application/json",
    "Authorization": "Bearer <redacted fictional placeholder>"
  },
  "timeoutSeconds": 5
}
CLIENT ASSESSMENTComplete, validated response

Every simulated page and device is valid. A local reporter could now prepare a stable output.

HTTP200
ReportReady
1 / 6

This is an explanatory simulation, not a live HTTP client. The Practice clinic executes Python functions against offline injected response fixtures. The optional local project runs an actual HTTP request only against a mock server bound to 127.0.0.1.

Remember: Handle HTTP status by meaning: 204 is not JSON, 401 differs from 403, and retryable conditions must be treated separately from permission problems.
BUILD REQUESTS WITHOUT STRING GUESSING
03

Encode query parameters and validate request destinations

Build URLs from a trusted base and encode query parameter values using urllib.parse.urlencode rather than concatenating untrusted strings. Spaces, ampersands and other characters have special roles in URL syntax. A site named 'HQ & East' should remain one query value, not become another parameter. Keep an explicit HTTPS origin allowlist for operational clients and validate hostnames before following URLs or redirects, especially pagination URLs returned by a server. Query strings often appear in proxies, histories and logs; never put access tokens or passwords there.

Escape data, not syntax

urlencode({'site':'HQ & East'}) generates a single encoded value. A literal '&' inserted into a raw URL would change the query structure and could silently alter server-side filters.

Request with a timeout

urllib.request.Request(url, headers={'Accept':'application/json'}, method='GET') describes the outgoing request. urllib.request.urlopen(request, timeout=5) sends it, but only in a real local Python process with network access.

Avoid server-directed arbitrary hosts

An API-provided nextLink or redirect must not make your tool contact an unapproved host or access a local metadata endpoint. Compare parsed scheme and hostname against your approved service origin.

PYTHON / PREDICT AND EXPLAINRead before you run
from urllib.parse import urlencode
from urllib.request import Request
url='https://api.example.invalid/v1/devices?'+urlencode({'site':'HQ & East'})
request=Request(url,headers={'Accept':'application/json'},method='GET')
print(request.full_url.endswith('site=HQ+%26+East'))
Why is adding an access token to the URL query string a poor substitute for Authorization headers? Reveal the explanation after predicting.

URL query strings are routinely recorded by browser history, proxies, server access logs and monitoring systems. Sensitive credentials should use an approved authentication mechanism, usually an Authorization header over HTTPS, with careful log redaction.

SYNTAX BRIDGE / BEFORE YOU CODE

Read every URL property your validator will inspect

Python's urlsplit exposes attributes the safe-continuation exercise needs. The main lesson shows scheme/path/query; the exercise additionally requires hostname, port and credential detection.

Python / inspect a fictional URLRUNNABLE PYTHON · READ FIRST
from urllib.parse import urlsplit

parts = urlsplit('https://api.example.invalid:443/v1/devices?page=2')
print(parts.scheme)
print(parts.hostname)
print(parts.port)
print(parts.username is None and parts.password is None)
print(parts.path)

.hostname normalizes the host, .port gives the integer port (or None when absent), and .username/.password reveal embedded credentials, which must be rejected. Accessing .port on some malformed values can raise ValueError; fail closed instead of following an untrusted URL.

Predict and explain: Should `https://user:pass@api.example.invalid/v1/devices` be followed simply because .hostname matches the allowed API?

No. Reject URLs that embed credentials, even when the hostname is correct.

Remember: Treat the URL origin as a trust boundary, encode query data, and keep credentials out of URLs and diagnostic output.
WHO ARE YOU AND WHAT MAY YOU DO?
04

Separate API authentication, authorization and consent

Authentication establishes an identity for a request; authorization determines what that identity can access. An API key identifies a caller only within its provider's defined scheme and may not represent a human user. OAuth 2.0 access tokens typically carry delegated or application permissions and are presented as Bearer tokens, but bearer possession alone grants access only within the token's audience, lifetime and scope. Microsoft Graph scenarios distinguish delegated user context from application-only permissions; an API call should request the minimum approved permissions for its work. Credentials are not necessary for our fictional offline simulator.

401: identify the credential problem

A missing, expired, invalid or wrong-audience token may lead to 401. Do not immediately generate an endless retry loop or paste the token into a ticket or chat transcript.

403: inspect authorized scope

A valid identity may still lack the role or scope required by the endpoint. Treat this as an authorization boundary: review permission grants, consent and the endpoint contract rather than escalating privileges casually.

Least privilege applies to scripts

A read-only reporting client should not request broad write permissions. Application permissions can operate without an interactive user, so their scope and secret storage deserve particular attention.

PYTHON / PREDICT AND EXPLAINRead before you run
def classify_auth(status):
    return {401:'Check authentication',403:'Review permissions'}.get(status,'Other status')
print(classify_auth(403))
An app has a valid token but the API returns 403. Is re-running the same credential flow guaranteed to fix it? Reveal the explanation after predicting.

No. A valid token can lack the appropriate delegated/application permission, role or resource access. Verify the endpoint's authorization requirements and approved grant before changing permissions. Reauthenticating without correcting scope often changes nothing.

Remember: Authentication is identity proof; authorization is an independent permission decision. Use least privilege and never treat tokens as proof of unrestricted access.
KEEP THE TOKEN OUT OF THE STORY
05

Protect tokens, credentials and diagnostic output

A bearer token is sensitive because anyone who obtains a usable one may be able to act with its permissions. Never embed real tokens in source control, lesson examples, browser code or query strings. Local environment variables may be practical for a short demonstration, but they are not a secret vault and may leak through process inspection or CI logs; production tools should use organization-approved credential management, rotating short-lived tokens and approved libraries or SDKs. Build an Authorization header only from a provided token value, avoid printing it, and redact known sensitive fields from errors.

Only fictional tokens in exercises

The browser uses a symbolic placeholder such as <redacted> and never asks for a personal token. There are no real authentication endpoints or credential exchanges in this learning page.

Log the outcome, not the credential

Record an HTTP status and a nonsensitive request identifier, not a bearer token, password, Authorization header or full credential-bearing URL.

Don't overinterpret environment variables

An environment variable can keep a token out of committed source, but processes, crash dumps or build logs may still expose it. Organizational secret stores and token rotation are better operational defaults.

PYTHON / PREDICT AND EXPLAINRead before you run
def safe_headers(headers):
    return {k:('<redacted>' if k.lower() in {'authorization','x-api-key'} else v)
            for k,v in headers.items()}
print(safe_headers({'Authorization':'Bearer DEMO','Accept':'application/json'}))
Is printing the Authorization header acceptable if you first replace only the word Bearer? Reveal the explanation after predicting.

No. The sensitive part is the token value after Bearer. Redact the entire sensitive header value, and also avoid accidentally logging query strings or exception objects that could contain credentials.

Remember: Treat credentials as sensitive data throughout their lifecycle: storage, transport, exceptions, tracing and debugging. Never request real tokens in JoeLearns.
NETWORK FAILURE IS A DIFFERENT KIND OF FAILURE
06

Handle timeouts and transport errors without hiding them

Even a correct API endpoint can be unreachable, slow or interrupted. `urllib.request.urlopen` accepts a timeout in seconds for blocking operations; failing to provide bounded network timeouts can stall scheduled jobs. Distinguish socket.timeout/TimeoutError and URLError transport failures from HTTPError responses, which carry a status code and often a body. A 200 status paired with invalid JSON is a response validation error, not a network timeout. Keep error messages clear enough to diagnose the stage without exposing secrets or response payloads unnecessarily.

HTTPError is an HTTP response

urllib raises HTTPError on many 4xx/5xx responses. It contains .code and .headers; handle these as service responses instead of telling the operator the internet is down.

A timeout is not zero results

A connection that never completes does not prove there are zero devices. Return a distinct failure state and do not overwrite a previously valid report.

Make transport mockable

A function that accepts a `fetch(url)` callback can be tested offline against response dictionaries and simulated failures. That separation makes retry, validation and status logic independently testable.

PYTHON / PREDICT AND EXPLAINRead before you run
def describe_result(result):
    if result['kind']=='timeout': return 'Transport timed out'
    if result['kind']=='http': return f"HTTP {result['status']}"
    return 'Invalid response envelope'
print(describe_result({'kind':'timeout'}))
Why must a 503 response and a timeout be represented differently in a reporting workflow? Reveal the explanation after predicting.

A 503 means the server returned an HTTP response indicating service unavailability, whereas a timeout means the client couldn't complete a response in time. Their diagnostic evidence and retry handling differ; neither should become a valid empty inventory.

Remember: Keep transport failures, HTTP statuses and malformed response bodies as separate outcomes. Always bound wait time and preserve known-good output on failure.
ONE PAGE IS RARELY THE WHOLE INVENTORY
07

Follow pagination safely and cap the work

Many directory and IT inventory APIs return only a subset of records per response. Microsoft Graph uses `value` arrays and may provide `@odata.nextLink` when another page exists. A client that reads one 200 response and stops may quietly omit most devices. Validate each page's shape, collect records in a deterministic order, and enforce a maximum number of pages and records. Maintain a set of visited URLs to catch repeated/cyclic links. Validate every nextLink against the approved HTTPS service origin before fetching it; never blindly follow a server-supplied URL to an arbitrary host.

The next link is a full continuation URL

Microsoft Graph often supplies an opaque @odata.nextLink that includes a server-generated paging token. Don't invent or modify that token to fetch later pages.

Stop only on a documented termination

An absent/null nextLink is normal completion. A repeated link, too many pages, invalid response shape or failed page is an error; do not publish a partial inventory as if it is complete.

Limit and verify the totals

Choose a bound such as 10 pages for the training client and check every page's value array. Sorting before reporting creates stable output even when the provider changes page order.

PYTHON / PREDICT AND EXPLAINRead before you run
def collect(page1,page2):
    return sorted(page1['value']+page2['value'])
print(collect({'value':['SW-02']},{'value':['AP-01']}))
Why is it dangerous to trust a @odata.nextLink URL without inspecting its scheme and hostname? Reveal the explanation after predicting.

A returned URL can direct a client away from the approved service to a different or internal host. When requests contain bearer tokens, following an untrusted link could expose credentials or trigger unintended network access. Validate each destination and keep requests on an allowlisted origin.

SYNTAX BRIDGE / BEFORE YOU CODE

Validate the next URL before asking a client to fetch it

The API capstone reuses the URL-safety rule on every page. Put the predicate in a helper, check for loops and restrict both the origin and allowed resource path.

Python / destination-only checkRUNNABLE PYTHON · READ FIRST
from urllib.parse import urlsplit

def allowed_page(url):
    try:
        p = urlsplit(url)
        return (p.scheme == 'https' and
                p.hostname == 'api.example.invalid' and
                p.port in (None, 443) and
                p.username is None and p.password is None and
                p.path == '/v1/devices')
    except ValueError:
        return False

print(allowed_page('https://api.example.invalid/v1/devices?page=2'))
print(allowed_page('https://evil.example/steal'))

This helper inspects a destination; it does not fetch the URL. The independent pagination challenge still needs you to enforce page limits, visited-link tracking and all-or-nothing reporting.

Predict and explain: Can validating only the first request URL protect a client that follows an untrusted nextLink from a later page?

No. Every continuation must pass the same destination checks and a cycle/page budget check before it is requested.

Remember: Handle API pagination as a bounded, validated continuation process. A single page is not a complete inventory, and failed pages aren't successful zero results.
RETRY WITH BOUNDS, NOT HOPE
08

Respect 429, Retry-After, backoff and idempotency

APIs protect their capacity by throttling clients. Microsoft Graph commonly responds with HTTP 429 and a Retry-After header containing a suggested wait interval. For a read-only GET, a limited retry with the server's guidance (and a fallback backoff policy when guidance is absent) can be reasonable. Never immediately hammer the endpoint or sleep unboundedly. Parse Retry-After carefully: HTTP permits both delta-seconds and HTTP-date, so the numeric-only helper in this lesson is an explicit simplified training subset. Changing operations such as POST must not be automatically retried without understanding idempotency keys, duplicate creation and the API's contract.

Retry-After gives a minimum wait

For a valid numeric Retry-After: 10, wait at least that many seconds before another request when permitted by your job deadline. Do not treat 10 as the number of attempts.

Cap attempts and time budget

A small bounded attempt count, fixed timeout per call and overall deadline prevent a scheduled job from hanging indefinitely. Record a clean failure after the budget is exhausted.

Never blindly retry writes

A POST might have succeeded even if the client lost its response. Replaying it can create duplicates. Only retry mutations with an endpoint-specific safe idempotency strategy and explicit operational approval.

PYTHON / PREDICT AND EXPLAINRead before you run
def retry_seconds(headers):
    value = headers.get('Retry-After','')
    return min(int(value),30) if value.isdecimal() else None
print(retry_seconds({'Retry-After':'10'}))
Would a report tool be correct to retry every 429 response immediately until it receives HTTP 200? Reveal the explanation after predicting.

No. Immediate retries can worsen throttling and consume a service's quota. Honor valid Retry-After guidance, enforce attempt and time limits, and stop when the budget is exhausted. For state-changing requests, retry policies must account for possible duplicate effects.

Remember: Make retries explicit, bounded and appropriate for the HTTP method. Respect throttling guidance without turning an unreliable service into an infinite loop.
BUILD A RELIABLE READ-ONLY API REPORTER
09

Connect API responses to your existing JSON pipeline

A practical API-based inventory reporter should use your earlier CLI for approved configuration, request data from a known service with the minimum permissions, interpret HTTP errors, page through complete results, validate JSON records, then build the stable report learned in Lesson 03. Design the core as an offline-testable function that takes an injected response provider, and keep actual network access in a separate adapter. The local example uses a tiny server bound to your own loopback address with fictional responses—no Microsoft tenant, API token, customer data or third-party package needed. After verifying that behavior, learners can adapt it to an authorized sandbox API in later training.

Test failures before any production integration

Simulate 200 valid JSON, 200 wrong shape, 204, 401, 403, 404, 429, 503, malformed paging links and a timeout. A reporting function should explicitly say which evidence is incomplete.

Read-only means read-only

Use GET and fictional fixtures in this lesson. No POST, PATCH or DELETE calls and no real access tokens are required. If a report cannot be completed, avoid replacing the last known-good result.

Keep operational decisions visible

Record how many pages were fetched, which nonsecret request ID was returned, and whether the report was complete. The next lesson expands this into logging, repeatable tests and resilience.

PYTHON / PREDICT AND EXPLAINRead before you run
def build_inventory_report(items):
    hosts=sorted(item['host'] for item in items)
    return {'total':len(hosts),'hosts':hosts}
print(build_inventory_report([{'host':'SW-02'},{'host':'AP-01'}]))
What should the reporter do after page 1 succeeds but page 2 fails with a timeout? Reveal the explanation after predicting.

Return an incomplete/error result and preserve any prior known-good report. Do not quietly publish the first page as the whole inventory. Record the timeout stage and continue only according to a bounded, approved retry policy.

Remember: An API report is trustworthy only when every required page and record passes validation. Keep network access, transformation and publication separate for testability.
LOCAL PROJECT / GENUINE HTTP WITHOUT CLOUD ACCESS
10

Run a real HTTP client against your own mock API

You can practice actual HTTP request and response behavior without a Microsoft tenant, external test account or real credentials. Save the Python script below as local_api_demo.py inside the virtual environment project from Lesson 01. It starts a temporary server on your computer's 127.0.0.1 interface with a random available port, makes GET requests using urllib.request, validates paged JSON, prints a report and shuts the server down. All devices are fictional and no files are modified.

What localhost means

127.0.0.1 is your own computer's loopback interface. The demo uses plain HTTP only on localhost with no credentials. Use HTTPS and an organization-approved authentication flow for actual remote services. If your device security policy blocks local listeners, use the browser exercises instead rather than changing that policy.

Why simulate failures?

Instead of depending on a public API being online, switch the demo to 401, 403, 429, 503 or malformed JSON. Test your program's error path under controlled conditions, then compare what it prints with the explanations above.

local_api_demo.py / COPY INTO YOUR LOCAL PROJECTReal local HTTP only
"""Offline REST API practice. Binds ONLY to 127.0.0.1; uses fictional inventory."""
import argparse
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, urlsplit
from urllib.request import Request, urlopen

DEVICES = [
    {"host": "SW-01", "status": "online"},
    {"host": "AP-02", "status": "offline"},
    {"host": "FW-01", "status": "online"},
]
SCENARIOS = ("normal", "auth", "forbidden", "throttle", "service", "bad-json")

class DemoAPI(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # Don't print request headers or credentials.

    def do_GET(self):
        parts = urlsplit(self.path)
        query = parse_qs(parts.query)
        scenario = query.get("scenario", ["normal"])[0]
        page = query.get("page", ["1"])[0]

        if parts.path != "/v1/devices":
            return self.send_json(404, {"error": "not-found"})
        if scenario == "auth":
            return self.send_json(401, {"error": "demo-auth-required"})
        if scenario == "forbidden":
            return self.send_json(403, {"error": "demo-permission-denied"})
        if scenario == "throttle":
            return self.send_json(429, {"error": "demo-throttled"}, {"Retry-After": "3"})
        if scenario == "service":
            return self.send_json(503, {"error": "demo-service-unavailable"})
        if scenario == "bad-json":
            return self.send_body(200, b"{not-json", "application/json")

        if page == "1":
            base = f"http://127.0.0.1:{self.server.server_port}"
            payload = {"value": DEVICES[:2], "@odata.nextLink": base + "/v1/devices?page=2"}
        elif page == "2":
            payload = {"value": DEVICES[2:]}
        else:
            return self.send_json(404, {"error": "page-not-found"})
        self.send_json(200, payload)

    def send_json(self, status, payload, extra=None):
        body = json.dumps(payload, sort_keys=True).encode("utf-8")
        self.send_body(status, body, "application/json", extra)

    def send_body(self, status, body, content_type, extra=None):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        for key, value in (extra or {}).items():
            self.send_header(key, value)
        self.end_headers()
        self.wfile.write(body)


def read_page(url, expected_port):
    parts = urlsplit(url)
    # HTTP is intentional ONLY for the local, fake server with no secrets.
    if (parts.scheme != "http" or parts.hostname != "127.0.0.1"
            or parts.port != expected_port or parts.username or parts.password
            or parts.path != "/v1/devices"):
        raise ValueError("Blocked unexpected continuation URL")
    req = Request(url, headers={"Accept": "application/json"}, method="GET")
    try:
        with urlopen(req, timeout=4) as response:
            status = response.status
            body = response.read(64 * 1024 + 1)
            if len(body) > 64 * 1024:
                raise ValueError("Response exceeded 64 KiB limit")
    except HTTPError as exc:
        hint = exc.headers.get("Retry-After")
        raise ValueError(f"HTTP {exc.code}" + (f"; Retry-After={hint}" if hint else "")) from None
    except (URLError, TimeoutError) as exc:
        raise ValueError("Network request failed or timed out") from exc
    if status != 200:
        raise ValueError(f"Unexpected HTTP {status}")
    try:
        data = json.loads(body.decode("utf-8"))
    except (UnicodeError, json.JSONDecodeError) as exc:
        raise ValueError("Invalid JSON response") from exc
    if not isinstance(data, dict) or not isinstance(data.get("value"), list):
        raise ValueError("Invalid API response shape")
    return data


def collect(base_url, port):
    seen = set()
    hosts = []
    url = base_url
    for _ in range(5):  # hard maximum, including the last page
        if url in seen:
            raise ValueError("Pagination loop")
        seen.add(url)
        page = read_page(url, port)
        for item in page["value"]:
            if (not isinstance(item, dict)
                    or not isinstance(item.get("host"), str)
                    or not item["host"].strip()
                    or item.get("status") not in ("online", "offline")):
                raise ValueError("Invalid device record")
            hosts.append({"host": item["host"].strip(), "status": item["status"]})
        next_url = page.get("@odata.nextLink")
        if not next_url:
            return {"total": len(hosts),
                    "offline_hosts": sorted(x["host"] for x in hosts if x["status"] == "offline"),
                    "pages": len(seen)}
        if not isinstance(next_url, str):
            raise ValueError("Invalid pagination link")
        url = next_url
    raise ValueError("Page budget exceeded; no partial report allowed")


def main(argv=None):
    parser = argparse.ArgumentParser(description="Fictional API over local loopback only")
    parser.add_argument("--scenario", choices=SCENARIOS, default="normal")
    args = parser.parse_args(argv)
    server = ThreadingHTTPServer(("127.0.0.1", 0), DemoAPI)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        first = f"http://127.0.0.1:{server.server_port}/v1/devices"
        if args.scenario != "normal":
            first += "?scenario=" + args.scenario
        report = collect(first, server.server_port)
        print(json.dumps(report, sort_keys=True, indent=2))
        return 0
    except ValueError as exc:
        print("Local API demo error: " + str(exc), file=sys.stderr)
        return 1
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


if __name__ == "__main__":
    raise SystemExit(main())

Run and compare these commands

python local_api_demo.pypython local_api_demo.py --scenario authpython local_api_demo.py --scenario forbiddenpython local_api_demo.py --scenario throttlepython local_api_demo.py --scenario bad-jsonpython local_api_demo.py --help

Use the virtual environment interpreter from Lesson 01. No pip install is needed for this standard-library demonstration.

Expected evidence

The normal run produces a two-page report with three fictional devices and one offline host, AP-02. The authentication simulation should produce a clear HTTP 401 error and a nonzero exit status; the throttling simulation shows an HTTP 429 and a Retry-After: 3 hint. The example reports failures rather than automatically sleeping or retrying.

Read the read_page() function closely: it allows only the exact loopback host and port chosen by the local server. The all-or-nothing collect() loop validates every page before printing a result. A normal cloud API client will need an HTTPS origin allowlist, approved credentials, robust retry limits and auditing before being trusted with operational data.

11 / APPLY THE CONCEPTS
11

Write API-handling logic yourself

Begin with an encoded read-only URL and work toward independent status handling, JSON schema checks, secret redaction, transport errors, rate limits, safe pagination URLs and a complete two-page inventory reporter. The workbench executes actual Python code and checks changed response fixtures without calling a live API.

JOELEARNS / PYTHON WORKBENCH

Write it. Run it. Understand it.

Write real Python response-handling code with offline HTTP fixtures, safe pagination, token redaction and bounded error decisions. No network requests or credentials.

Python 3.14 · in-browser
CHALLENGE 01 / 8 · BEGINNER

Build a properly encoded read-only URL

Implement make_devices_url(site), which builds a GET endpoint URL starting with https://api.example.invalid/v1/devices and exactly one query argument named site. Use urllib.parse.urlencode rather than manually concatenating the site string. Strip leading/trailing whitespace from site; return None for empty or non-string input. Print the URL for 'HQ & East'. Additional cases include spaces, ampersands and punctuation, and verify that parsing recovers the original site as one argument.

EXPECTED OUTPUThttps://api.example.invalid/v1/devices?site=HQ+%26+East
script.py editableCtrl / Cmd + Enter to run
Your code is stored only in session storage when enabled. Never paste secrets. Clearing this option clears saved drafts, not the editor.
Code executes only when you choose Run or Check. No account needed.
HINTS · OPTIONAL

Try solving it before asking for help. Hints never show a completed program.

PYTHON OUTPUTReady

Your printed output and errors will appear here.

VARIABLE TRACER

Run the code, then step through the executed lines to watch variable values change.

About this environment: The Python runtime is loaded on demand from a version-pinned CDN and runs inside a sandboxed, opaque-origin browser frame. Runs have a time limit. OS/network automation is outside this introductory workbench. If your code calls input(), a response panel appears to supply text to those calls. Don't paste secrets or untrusted scripts.

Next required step: complete the 8-challenge Practice milestone →
NEXT / PRACTICE

Don't stop at the first 200 OK.

Use Check to verify your code handles 204, 401, 403, malformed data, unsafe pagination and timeouts. A valid API inventory requires every required page to succeed, not just the first request.

Continue to all eight API coding challenges →Next: Logging, Testing & Reliability →Applied Python path →
12

Official references

The built-in Python modules and Microsoft Graph documentation underpin these examples. Each real service has its own supported endpoints, authentication requirements, paging contracts and rate limits; verify its current guidance before integrating a real environment.

NEXT REQUIRED STEP · APPLIED PYTHON FOR IT AUTOMATION

Complete the Practice milestone

You've reached the end of this written lesson. Your next step is practice—complete REST APIs & Authentication Coding Clinic before proceeding to a new lesson. This is milestone 8 of 13. You can revisit the explanation whenever you need it.