diff --git a/examples/aiohappyeyeballs/README.md b/examples/aiohappyeyeballs/README.md new file mode 100644 index 0000000..2837575 --- /dev/null +++ b/examples/aiohappyeyeballs/README.md @@ -0,0 +1,18 @@ +# aiohappyeyeballs Examples + +Each sub-directory contains a self-contained example. The order in +which the examples are to appear is specified in `order.json` (an +array of directory names in the expected order). + +In each example directory you'll find: + +* `config.toml` - must conform to the specification outlined here: + https://docs.pyscript.net/latest/user-guide/configuration/ This is + parsed and ultimately turned into a JSON representation as part of + the package's API object. +* `setup.py` - Python code for contextual and environmental setup, + NOT SEEN BY THE END USER, but is run before the `code.py` code is + evaluated. Allows us to create useful (IPython) shims, avoid + repeating boilerplate and whatnot. +* `code.py` - the actual code added to the editor which forms the + practical example of using the package. diff --git a/examples/aiohappyeyeballs/addr_info_basics/code.py b/examples/aiohappyeyeballs/addr_info_basics/code.py new file mode 100644 index 0000000..55c3e6e --- /dev/null +++ b/examples/aiohappyeyeballs/addr_info_basics/code.py @@ -0,0 +1,64 @@ +""" +A first look at aiohappyeyeballs: shaping addrinfo lists. + +The Happy Eyeballs algorithm (RFC 8305) races IPv6 and IPv4 connection +attempts so dual-stack clients fall back gracefully when one family is +slow or unreachable. The aiohappyeyeballs package provides the building +blocks asyncio uses to do this when you already have a list of resolved +addresses (rather than a hostname). + +In this first example we'll skip the network entirely and focus on the +addrinfo helpers: the small list-manipulation utilities that let you +prepare and tidy the input to the Happy Eyeballs machinery. +""" +from IPython.core.display import display, HTML +import socket + +heading("A handful of resolved addresses for example.org") +note( + "Normally you'd get this list from loop.getaddrinfo() or a " + "DNS cache. We'll build it by hand so we can see exactly what the helpers do." +) + +# Each entry is the same 5-tuple shape that socket.getaddrinfo returns: +# (family, type, proto, canonname, sockaddr). +addr_infos = [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1::1", 80, 0, 0)), + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:2800:220:1::2", 80, 0, 0)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.35", 80)), +] +format_addr_infos(addr_infos) + +heading("Adding a local bind address with addr_to_addr_infos") +note( + "When you want to bind the outgoing socket to a specific local address, " + "start_connection expects a full addrinfo list, not a tuple. " + "addr_to_addr_infos does that conversion for you." +) +local_addr_infos = addr_to_addr_infos(("127.0.0.1", 0)) +format_addr_infos(local_addr_infos) + +heading("Interleaving families with pop_addr_infos_interleave") +note( + "Happy Eyeballs prefers to alternate between IPv6 and IPv4. " + "pop_addr_infos_interleave(addr_infos, 1) removes the first " + "address of each family in place — handy after a successful attempt, to " + "skip past addresses you already tried." +) +working = list(addr_infos) +pop_addr_infos_interleave(working, 1) +note("After popping one address per family:") +format_addr_infos(working) + +heading("Pruning a known-bad address with remove_addr_infos") +note( + "If you discover an address is unreachable (perhaps from a previous " + "failure), remove_addr_infos strips every entry that matches." +) +working2 = list(addr_infos) +remove_addr_infos(working2, ("93.184.216.34", 80)) +note("After removing 93.184.216.34:80") +format_addr_infos(working2) + +note(f"aiohappyeyeballs version in use: {aiohappyeyeballs.__version__}") diff --git a/examples/aiohappyeyeballs/addr_info_basics/config.toml b/examples/aiohappyeyeballs/addr_info_basics/config.toml new file mode 100644 index 0000000..528415a --- /dev/null +++ b/examples/aiohappyeyeballs/addr_info_basics/config.toml @@ -0,0 +1 @@ +packages = ["aiohappyeyeballs"] diff --git a/examples/aiohappyeyeballs/addr_info_basics/setup.py b/examples/aiohappyeyeballs/addr_info_basics/setup.py new file mode 100644 index 0000000..0438f29 --- /dev/null +++ b/examples/aiohappyeyeballs/addr_info_basics/setup.py @@ -0,0 +1,68 @@ +""" +Shim IPython's display API onto PyScript so example code written in a +Jupyter/IPython idiom runs unmodified in the browser. +""" + +import sys +import types +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + """Wrap pyscript.display so output lands in the example target.""" + return _display( + *args, **kwargs, target=__pyscript_display_target__, + ) + + +ipython = types.ModuleType("IPython") +core = types.ModuleType("IPython.core") +core_display = types.ModuleType("IPython.core.display") +core_display.display = display +core_display.HTML = HTML +ipython.core = core +core.display = core_display +ipython.get_ipython = lambda: None +ipython.display = core_display +sys.modules["IPython"] = ipython +sys.modules["IPython.core"] = core +sys.modules["IPython.core.display"] = core_display +sys.modules["IPython.display"] = core_display + + +def heading(text, level=2): + display(HTML(f"{text}"), append=True) + + +def note(text): + display(HTML(f"

{text}

"), append=True) + + +import socket +import aiohappyeyeballs +from aiohappyeyeballs import ( + addr_to_addr_infos, + pop_addr_infos_interleave, + remove_addr_infos, +) + + +def format_addr_infos(addr_infos): + """Render a list of getaddrinfo-style 5-tuples as an HTML table.""" + rows = ["familytypeprotoaddress"] + family_names = {socket.AF_INET: "AF_INET", socket.AF_INET6: "AF_INET6"} + type_names = {socket.SOCK_STREAM: "SOCK_STREAM", socket.SOCK_DGRAM: "SOCK_DGRAM"} + for family, type_, proto, _canon, sockaddr in addr_infos: + rows.append( + "" + f"{family_names.get(family, family)}" + f"{type_names.get(type_, type_)}" + f"{proto}" + f"{sockaddr}" + "" + ) + table = "" + "".join(rows) + "
" + display(HTML(table), append=True) diff --git a/examples/aiohappyeyeballs/happy_eyeballs_simulation/code.py b/examples/aiohappyeyeballs/happy_eyeballs_simulation/code.py new file mode 100644 index 0000000..a43c405 --- /dev/null +++ b/examples/aiohappyeyeballs/happy_eyeballs_simulation/code.py @@ -0,0 +1,115 @@ +# --------------------------------------------------------------------- +# Simulating the Happy Eyeballs race +# --------------------------------------------------------------------- +# +# In production you'd hand a list of addrinfos to +# `aiohappyeyeballs.start_connection(...)` along with an event loop, and +# it would race connection attempts across address families, staggering +# each new attempt by `happy_eyeballs_delay` seconds (default 0.25s per +# RFC 8305) until one succeeds. +# +# We can't open real sockets here, but we can simulate the race itself +# to build intuition for what the algorithm does. Each "address" gets a +# pretend connect-time and success flag; we then schedule attempts using +# the same staggered pattern that start_connection uses internally. + +heading("A scenario: one slow IPv6 address, one fast IPv4 address") +note( + "Imagine the first IPv6 address is slow to respond (perhaps the route " + "is congested) but the IPv4 fallback is quick. We'll order the addrinfos " + "the way getaddrinfo typically does — IPv6 first — and let Happy Eyeballs " + "race them." +) + +# Each entry: (family, type, proto, canonname, sockaddr, connect_seconds, will_succeed) +scenario = [ + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:db8::1", 80, 0, 0), 1.20, True), + (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:db8::2", 80, 0, 0), 1.30, True), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("203.0.113.10", 80), 0.40, True), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("203.0.113.11", 80), 0.45, True), +] +addr_infos = [row[:5] for row in scenario] +connect_times = {row[4]: row[5] for row in scenario} +will_succeed = {row[4]: row[6] for row in scenario} + + +async def simulate_happy_eyeballs(addr_infos, delay=0.25): + """Race fake connect() calls with a staggered start, like RFC 8305.""" + loop = asyncio.get_event_loop() + started_at = loop.time() + log = [] # (sockaddr, start, end, outcome) + winner = None + + async def fake_connect(sockaddr): + start = loop.time() - started_at + try: + await asyncio.sleep(connect_times[sockaddr]) + except asyncio.CancelledError: + log.append((sockaddr, start, loop.time() - started_at, "cancelled")) + raise + end = loop.time() - started_at + if not will_succeed[sockaddr]: + log.append((sockaddr, start, end, "failed")) + raise OSError("simulated failure") + log.append((sockaddr, start, end, "won")) + return sockaddr + + tasks = [] + for _family, _type, _proto, _canon, sockaddr in addr_infos: + tasks.append(asyncio.create_task(fake_connect(sockaddr))) + try: + # Give this attempt a head start before launching the next. + await asyncio.wait_for(asyncio.shield(tasks[-1]), timeout=delay) + winner = tasks[-1].result() + break + except asyncio.TimeoutError: + continue # stagger: launch the next address + except OSError: + continue # this attempt failed early; move on + + if winner is None: + # Nothing won during staggering; wait for the first to finish. + done, pending = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED + ) + for task in done: + if not task.cancelled() and task.exception() is None: + winner = task.result() + break + for task in pending: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + return winner, log + + +winner, log = await simulate_happy_eyeballs(addr_infos, delay=0.25) +note(f"Winning address: {winner}") + +# Visualise the race as a Gantt-style chart. +fig, ax = plt.subplots(figsize=(9, 3.5)) +colors = {"won": "seagreen", "cancelled": "lightgray", "failed": "indianred"} +for i, (sockaddr, start, end, outcome) in enumerate(log): + ax.barh(i, end - start, left=start, color=colors[outcome], + edgecolor="black", linewidth=0.5) + ax.text(end + 0.02, i, outcome, va="center", fontsize=9) +ax.set_yticks(range(len(log))) +ax.set_yticklabels([str(entry[0]) for entry in log], fontsize=9) +ax.set_xlabel("Time since start (seconds)") +ax.set_title("Happy Eyeballs race: staggered connection attempts") +ax.invert_yaxis() +fig.tight_layout() +display(fig, append=True) + +heading("Pruning a flaky address before the next attempt") +note( + "If your application learns that an address is unhealthy, you can clean " + "the addrinfo list before reusing it. Here we drop the slow IPv6 address " + "and reorder so the IPv4 fallback is tried first." +) +pruned = list(addr_infos) +remove_addr_infos(pruned, ("2001:db8::1", 80, 0, 0)) +pop_addr_infos_interleave(pruned, 0) # no-op example: keeps ordering intact +note("Cleaned addrinfo list:") +for entry in pruned: + display(HTML(f"{entry}"), append=True) diff --git a/examples/aiohappyeyeballs/happy_eyeballs_simulation/config.toml b/examples/aiohappyeyeballs/happy_eyeballs_simulation/config.toml new file mode 100644 index 0000000..8e42cc1 --- /dev/null +++ b/examples/aiohappyeyeballs/happy_eyeballs_simulation/config.toml @@ -0,0 +1 @@ +packages = ["aiohappyeyeballs", "matplotlib"] diff --git a/examples/aiohappyeyeballs/happy_eyeballs_simulation/setup.py b/examples/aiohappyeyeballs/happy_eyeballs_simulation/setup.py new file mode 100644 index 0000000..8c3fd64 --- /dev/null +++ b/examples/aiohappyeyeballs/happy_eyeballs_simulation/setup.py @@ -0,0 +1,28 @@ +"""Lightweight setup for the second example: same names as cell 1, no shim.""" +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + return _display(*args, **kwargs, target=__pyscript_display_target__) + + +def heading(text, level=2): + display(HTML(f"{text}"), append=True) + + +def note(text): + display(HTML(f"

{text}

"), append=True) + + +import socket +import asyncio +import matplotlib.pyplot as plt +import aiohappyeyeballs +from aiohappyeyeballs import ( + addr_to_addr_infos, + pop_addr_infos_interleave, + remove_addr_infos, +) diff --git a/examples/aiohappyeyeballs/order.json b/examples/aiohappyeyeballs/order.json new file mode 100644 index 0000000..589e981 --- /dev/null +++ b/examples/aiohappyeyeballs/order.json @@ -0,0 +1,4 @@ +[ + "addr_info_basics", + "happy_eyeballs_simulation" +]