Filters
How to search
  • Plain words find names, texts and examples.
  • "two words" finds the exact phrase.
  • --name or -x finds an option.
  • #word looks only inside commands.
  • @grep finds lessons and labs that use grep.
  • Filters: cat:network · in:labs · in:notes · level:rhcsa · ch:8 · root:yes. Put ! before a word to leave it out. OR between words means one of them.
  • ↑ ↓ to move · Enter to open · Esc to close · / to search

Python scriptsNetwork series

HTTP timing phases in Python

A page takes 2.4 seconds and the dashboard says the server is fine. The time is in the phases before the request reaches the application, and a stopwatch around the whole URL hides all of them.

Quick reference

The full script is at the end of this note: http_timing.py (standard library only, no pip install).

Run it

python3 http_timing.py https://example.com --budget 1.5

Example output

text
dns 2.1ms tcp 18.4ms tls 41.2ms ttfb 96.0ms
total 157 ms

Explained

What it measures

The script resolves the hostname, opens a TCP connection, performs the TLS handshake, sends one GET and waits for the first byte of the response. Each phase gets its own timer, from the same monotonic clock, so the rows add up to something you can defend.

The table

It prints five rows: dns, tcp, tls, ttfb and total, each in milliseconds. A slow DNS row means the resolver or a search domain. A slow TCP row means the path or a full accept queue. A slow TLS row means a long certificate chain or a cold session cache. A slow TTFB row means the application, or a proxy waiting on it.

The budget

Pass --budget 1.5 and the script exits 1 when the total exceeds 1.5 seconds. That makes it a check, not a dashboard: run it from cron against your own URL, or as a post-deploy step, and let the exit code fail the job. The full script is attached, standard library only and read-only.

The honest caveat: one sample from one machine measures that machine's path, not your users'. Run it from the region you care about, and compare distributions rather than single runs.

The lesson: a page is a sum of phases, so measure the phases.

In one line

Measure the hop, not the whole. A slow page is a sum of phases you never separated.

Run it in ci

python3 http_timing.py https://example.com

Script: http_timing.py

Download: scripts/http_timing.py

python
#!/usr/bin/env python3
"""Measure the HTTP timing phases of one URL.
 
    python3 http_timing.py https://example.com --budget 1.5
    python3 http_timing.py http://127.0.0.1:8080/ --budget 0.25
 
Prints dns, tcp, tls and ttfb, then total to the first response byte,
in milliseconds. Exits 1 when total exceeds --budget, and 2 on a usage,
URL or connection error. The request is one GET; nothing is written.
 
Cron/CI: python3 http_timing.py "$URL" --budget 2 || exit 1
By s0sta.com
"""
import argparse
import socket
import ssl
import sys
import time
from urllib.parse import urlsplit
 
 
def parse_url(url):
    """Return scheme, host, port and request path for an http(s) URL."""
    parts = urlsplit(url)
    if parts.scheme not in ("http", "https"):
        raise ValueError("URL must start with http:// or https://")
    if not parts.hostname:
        raise ValueError("URL has no hostname")
    try:
        port = parts.port
    except ValueError as exc:
        raise ValueError("URL has an invalid port") from exc
    if port is None:
        port = 443 if parts.scheme == "https" else 80
    path = parts.path or "/"
    if parts.query:
        path += "?" + parts.query
    return parts.scheme, parts.hostname, port, path
 
 
def tcp_connect(infos, timeout):
    """Connect to the first working address and return the socket."""
    last = None
    for family, socktype, proto, _, sockaddr in infos:
        sock = socket.socket(family, socktype, proto)
        sock.settimeout(timeout)
        try:
            sock.connect(sockaddr)
            return sock
        except OSError as exc:
            last = exc
            sock.close()
    raise last or OSError("no address to connect to")
 
 
def measure(url, timeout, insecure):
    """Time each phase of one GET, in milliseconds."""
    scheme, host, port, path = parse_url(url)
    started = time.perf_counter()
    infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    after_dns = time.perf_counter()
    sock = tcp_connect(infos, timeout)
    after_tcp = time.perf_counter()
    if scheme == "https":
        context = ssl.create_default_context()
        if insecure:
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
        sock = context.wrap_socket(sock, server_hostname=host)
    after_tls = time.perf_counter()
    default_port = 443 if scheme == "https" else 80
    shown = "[%s]" % host if ":" in host else host
    host_header = shown if port == default_port else "%s:%d" % (shown, port)
    request = (
        "GET %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "User-Agent: http-timing/1.0\r\n"
        "Accept: */*\r\n"
        "Connection: close\r\n\r\n" % (path, host_header)
    ).encode("ascii", "replace")
    sock.sendall(request)
    first = sock.recv(1)
    after_first = time.perf_counter()
    sock.close()
    if not first:
        raise OSError("the server closed before the first byte")
    return {
        "dns": (after_dns - started) * 1000,
        "tcp": (after_tcp - after_dns) * 1000,
        "tls": (after_tls - after_tcp) * 1000,
        "ttfb": (after_first - after_tls) * 1000,
        "total": (after_first - started) * 1000,
    }
 
 
def report(phases, budget):
    """Print the phase table and return the process exit code."""
    total = phases["total"]
    print("%-8s%9s%8s" % ("phase", "ms", "share"))
    for name in ("dns", "tcp", "tls", "ttfb", "total"):
        ms = phases[name]
        share = (ms / total * 100) if total > 0 else 0.0
        print("%-8s%9.1f%7.1f%%" % (name, ms, share))
    if total > budget * 1000:
        print("OVER budget by %.1f ms" % (total - budget * 1000))
        return 1
    print("within budget: %.1f ms of %.0f ms" % (total, budget * 1000))
    return 0
 
 
def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Measure DNS, TCP, TLS, TTFB and total for one URL.")
    parser.add_argument("url", help="http:// or https:// URL to time")
    parser.add_argument("--budget", type=float, default=1.5,
                        help="total budget in seconds (default: 1.5)")
    parser.add_argument("--timeout", type=float, default=10.0,
                        help="connect and read timeout in seconds (default: 10)")
    parser.add_argument("--insecure", action="store_true",
                        help="skip TLS verification (test fixtures only)")
    args = parser.parse_args(argv)
    if args.budget <= 0:
        parser.error("--budget must be greater than zero")
    if args.timeout <= 0:
        parser.error("--timeout must be greater than zero")
    try:
        phases = measure(args.url, args.timeout, args.insecure)
    except ValueError as exc:
        print("ERROR %s" % exc, file=sys.stderr)
        return 2
    except ssl.SSLError as exc:
        print("ERROR TLS handshake failed: %s" % exc, file=sys.stderr)
        return 2
    except OSError as exc:
        print("ERROR %s" % exc, file=sys.stderr)
        return 2
    return report(phases, args.budget)
 
 
if __name__ == "__main__":
    sys.exit(main())

More notes: Python scripts

Open in the notes app: search, filters, copy This note on GitHub