HTTP Redirect Limits: One Ouroboros, Six Languages
Table of Contents
- 1. The experiment
- 2. curl (baseline)
- 3. Python (requests)
- 4. Python (urllib)
- 5. Node.js (fetch / undici)
- 6. Deno (fetch)
- 7. Go (net/http)
- 8. Perl (LWP)
- 9. Ruby (net/http)
- 10. Rust (reqwest)
- 11. Crystal
- 12. Clojure / Babashka
- 13. Erlang (httpc)
- 14. Elixir (httpc via OTP)
- 15. Racket
- 16. Guile
- 17. Not tested
- 18. Results
- 19. Analysis
1. The experiment
The TLA+ redirect model documents two ouroboros canaries on wal.sh:
- /ouroboros/a – swap loop (a ↔ b, period 2)
- /ouroboros/c – grow chain (c → cc → ccc → …)
Each HTTP client follows the 301 chain until it hits its internal redirect limit. The limit varies by client. This note measures the actual limit for each language runtime on the test machine. Results are point-in-time; defaults may change across library versions.
1.1. Test environment
FreeBSD nexus 15.0-RELEASE FreeBSD 15.0-RELEASE releng/15.0-n280995-7aedc8de6446 GENERIC amd64
| Runtime | Version |
|---|---|
| curl | 8.20.0 |
| Python | 3.11.15 (requests 2.x, urllib stdlib) |
| Node.js | 24.12.0 (undici fetch) |
| Deno | 2.4.5 |
| Go | 1.24.11 (net/http stdlib) |
| Perl | 5.42.2 (LWP::UserAgent) |
| Ruby | 3.3.10 (net/http stdlib) |
| Crystal | 1.18.2 (http::Client stdlib) |
| Babashka | 1.12.206 (java.net.URL) |
| Racket | 9.0 [cs] (net/url stdlib) |
| Guile | 3.0.10 (web client, no gnutls) |
| Erlang/OTP | 26 (httpc / inets) |
| Elixir | 1.17.3 (OTP httpc) |
Each test sends a custom User-Agent (wal-sh-test/<lang>) so we can verify
server-side via gmake logs. Every code block has a :tangle target for
isolated execution.
2. curl (baseline)
curl -sIL -A "wal-sh-test/curl" --max-redirs 100 https://wal.sh/ouroboros/a 2>&1 \ | grep -c 'HTTP/' | xargs printf "curl (max-redirs 100): %s responses\n" curl -sIL -A "wal-sh-test/curl-default" https://wal.sh/ouroboros/a 2>/dev/null \ | grep -c 'HTTP/' | xargs printf "curl (default): %s responses\n"
Default limit: 50. (51 responses = 50 redirects + 1 final 301.)
3. Python (requests)
import requests try: r = requests.get("https://wal.sh/ouroboros/a", allow_redirects=True, headers={"User-Agent": "wal-sh-test/python-requests"}) print(f"python requests: status {r.status_code} after {len(r.history)} redirects") except requests.exceptions.TooManyRedirects as e: hist = e.response.history if e.response else [] print(f"python requests: TooManyRedirects after {len(hist)} hops") except Exception as e: print(f"python requests: {type(e).__name__}: {e}")
Default limit: 30.
4. Python (urllib)
import urllib.request req = urllib.request.Request("https://wal.sh/ouroboros/a", headers={"User-Agent": "wal-sh-test/python-urllib"}) try: urllib.request.urlopen(req) except urllib.error.HTTPError as e: print(f"python urllib: HTTPError {e.code}") except Exception as e: print(f"python urllib: {type(e).__name__}: {e}")
Default limit: ~10 (urllib detects the loop heuristically, not by counting).
5. Node.js (fetch / undici)
try { const res = await fetch("https://wal.sh/ouroboros/a", { redirect: "follow", headers: { "User-Agent": "wal-sh-test/node-fetch" } }); console.log(`node fetch: status ${res.status}`); } catch (e) { console.log(`node fetch: ${e.constructor.name}: ${e.message}`); }
Default limit: 20. (21 server-side hits: 20 redirects + 1 that triggers the error.)
6. Deno (fetch)
try { const res = await fetch("https://wal.sh/ouroboros/a", { redirect: "follow", headers: { "User-Agent": "wal-sh-test/deno" } }); console.log(`deno fetch: status ${res.status}`); } catch (e) { console.log(`deno fetch: ${(e as Error).constructor.name}: ${(e as Error).message}`); }
Default limit: 20. Deno's error message includes the limit explicitly.
7. Go (net/http)
package main import ( "fmt" "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("HEAD", "https://wal.sh/ouroboros/a", nil) req.Header.Set("User-Agent", "wal-sh-test/go") resp, err := client.Do(req) if err != nil { fmt.Printf("go net/http: %T: %v\n", err, err) return } fmt.Printf("go net/http: status %d\n", resp.StatusCode) }
Default limit: 10. The most conservative auto-following client.
8. Perl (LWP)
use LWP::UserAgent; # Default my $ua = LWP::UserAgent->new(agent => 'wal-sh-test/perl-default'); my $resp = $ua->head('https://wal.sh/ouroboros/a'); my @r = $resp->redirects; printf "perl LWP (default): %d redirects, final status %s\n", scalar(@r), $resp->status_line; # With high limit my $ua2 = LWP::UserAgent->new(agent => 'wal-sh-test/perl', max_redirect => 100); my $resp2 = $ua2->head('https://wal.sh/ouroboros/a'); my @r2 = $resp2->redirects; printf "perl LWP (max_redirect 100): %d redirects, final status %s\n", scalar(@r2), $resp2->status_line;
Default limit: 7. The lowest of any auto-following client tested.
9. Ruby (net/http)
Ruby's net/http does not follow redirects automatically.
require 'net/http' require 'uri' url = 'https://wal.sh/ouroboros/a' hops = 0 loop do uri = URI(url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true req = Net::HTTP::Head.new(uri) req['User-Agent'] = 'wal-sh-test/ruby' resp = http.request(req) break unless resp.is_a?(Net::HTTPRedirection) && hops < 100 url = resp['location'] hops += 1 end puts "ruby net/http: #{hops} hops (manual, no built-in limit)"
No auto-follow; no built-in limit. The caller must implement both.
10. Rust (reqwest)
Not tested inline (reqwest requires Cargo.toml + compile). Documented
default: 10 (redirect::Policy::limited(10)). Same as Go.
11. Crystal
Crystal's HTTP::Client does not follow redirects automatically.
require "http/client"
hops = 0
url = "https://wal.sh/ouroboros/a"
loop do
uri = URI.parse(url)
client = HTTP::Client.new(uri)
resp = client.head(uri.request_target,
headers: HTTP::Headers{"User-Agent" => "wal-sh-test/crystal"})
break unless resp.status.redirection? && hops < 100
loc = resp.headers["Location"]?
break unless loc
url = loc
hops += 1
end
puts "crystal: #{hops} hops (manual, no built-in limit)"
No auto-follow; no built-in limit.
12. Clojure / Babashka
Manual redirect count via java.net.URL:
(import '[java.net URL HttpURLConnection]) (defn count-redirects [url max-hops] (loop [u url hops 0] (if (>= hops max-hops) {:hops hops :status :limit-hit} (let [conn (doto (.openConnection (URL. u)) (.setInstanceFollowRedirects false) (.setRequestMethod "HEAD") (.setRequestProperty "User-Agent" "wal-sh-test/bb") (.connect)) code (.getResponseCode conn) loc (.getHeaderField conn "Location")] (.disconnect conn) (if (and (<= 300 code 399) loc) (recur loc (inc hops)) {:hops hops :status code}))))) (println (str "clojure manual: " (count-redirects "https://wal.sh/ouroboros/a" 100)))
Java's HttpURLConnection.setInstanceFollowRedirects(true) defaults to 20.
Manual loop has no built-in limit.
13. Erlang (httpc)
#!/usr/bin/env escript
count(_, Max, Hops) when Hops >= Max ->
io:format("erlang manual: ~p hops (limit hit)~n", [Hops]);
count(Url, Max, Hops) ->
case httpc:request(head, {Url, [{"user-agent", "wal-sh-test/erlang"}]},
[{autoredirect, false}], []) of
{ok, {{_, Code, _}, Headers, _}} when Code >= 300, Code < 400 ->
case proplists:get_value("location", Headers) of
undefined -> io:format("erlang: ~p hops, no location~n", [Hops]);
Loc -> count(Loc, Max, Hops + 1)
end;
{ok, {{_, Code, _}, _, _}} ->
io:format("erlang: ~p hops, status ~p~n", [Hops, Code]);
{error, Reason} ->
io:format("erlang: ~p hops, error ~p~n", [Hops, Reason])
end.
main(_) ->
inets:start(), ssl:start(),
count("https://wal.sh/ouroboros/a", 100, 0).
Erlang httpc with {autoredirect, true} has an undocumented low limit
(observed: 5 in some OTP versions). Manual loop has no limit.
14. Elixir (httpc via OTP)
Application.ensure_all_started(:inets)
Application.ensure_all_started(:ssl)
defmodule RedirectTest do
def count(url, max, hops \\ 0)
def count(_url, max, hops) when hops >= max do
IO.puts("elixir manual: #{hops} hops (limit hit)")
end
def count(url, max, hops) do
case :httpc.request(:head, {~c"#{url}", [{~c"user-agent", ~c"wal-sh-test/elixir"}]},
[autoredirect: false], []) do
{:ok, {{_, code, _}, headers, _}} when code >= 300 and code < 400 ->
case :proplists.get_value(~c"location", headers) do
:undefined -> IO.puts("elixir: #{hops} hops, no location")
loc -> count(List.to_string(loc), max, hops + 1)
end
{:ok, {{_, code, _}, _, _}} ->
IO.puts("elixir: #{hops} hops, status #{code}")
{:error, reason} ->
IO.puts("elixir: #{hops} hops, error #{inspect(reason)}")
end
end
end
RedirectTest.count("https://wal.sh/ouroboros/a", 100)
Elixir uses OTP's httpc underneath. Same behavior as Erlang.
15. Racket
Racket's net/url does not follow redirects automatically.
#lang racket/base (require net/url) (define (count-redirects url-str max-hops) (let loop ([u url-str] [hops 0]) (cond [(>= hops max-hops) (printf "racket: ~a hops (limit hit)\n" hops)] [else (define p (head-impure-port (string->url u) (list "User-Agent: wal-sh-test/racket"))) (define line (read-line p)) (define headers (port->string p)) (close-input-port p) (define m (regexp-match #rx"HTTP/[0-9.]+ ([0-9]+)" line)) (define code (if m (string->number (cadr m)) 0)) (define loc-m (regexp-match #rx"(?i:location): ([^\r\n]+)" headers)) (define loc (and loc-m (cadr loc-m))) (if (and (>= code 300) (< code 400) loc) (loop loc (+ hops 1)) (printf "racket: ~a hops, status ~a\n" hops code))]))) (count-redirects "https://wal.sh/ouroboros/a" 100)
No auto-follow; no built-in limit.
16. Guile
(use-modules (web client) (web response) (web uri)) (define (count-redirects url max-hops) (let loop ((u url) (hops 0)) (if (>= hops max-hops) (format #t "guile3: ~a hops (limit hit)~%" hops) (catch 'gnutls-not-available (lambda () (call-with-values (lambda () (http-head (string->uri u) #:headers `((user-agent . "wal-sh-test/guile3")))) (lambda (resp body) (let ((code (response-code resp)) (loc (response-location resp))) (if (and (>= code 300) (< code 400) loc) (loop (uri->string loc) (1+ hops)) (format #t "guile3: ~a hops, status ~a~%" hops code)))))) (lambda (key . args) (format #t "guile3: TLS error at hop ~a~%" hops)))))) (count-redirects "http://wal.sh/ouroboros/a" 100)
Guile 3.0.10 on FreeBSD 15.0 has the gnutls C library (3.8.13) but not
the (gnutls) Scheme module (guile-gnutls is not packaged in FreeBSD
ports). The (web client) module handles HTTP redirects fine – it follows
the first hop (http:// → https://) – but cannot complete the TLS
handshake on the HTTPS target. The redirect limit of Guile's HTTP client
is untestable on this platform without a plain-HTTP test endpoint or
building guile-gnutls from source.
17. Not tested
| Language | Version on box | HTTP library | Documented limit | Why not tested |
|---|---|---|---|---|
| Rust | 1.92.0 | reqwest | 10 | Requires Cargo project + compile |
| Haskell | GHC 9.10.3 | http-client | 10 | Package not in default GHC install |
| Kotlin | 2.3.0 (JVM 21) | OkHttp/ktor | ~20 (JVM default) | JVM HTTP behavior same as Clojure |
| .NET | 9.0.115 | HttpClient | 50 | Project scaffolding required |
| Julia | 1.10.5 | HTTP.jl | 3 | Package install required |
| SBCL | 2.5.7 | dexador/drakma | varies | Package install required |
| ECL | 24.5.10 | -- | -- | No HTTP library |
| Zig | 0.15.2 | std.http | unknown | No stable HTTP client API yet |
| CHICKEN | 5.4.0 | http-client egg | unknown | Egg install required |
| Guile | 3.0.10 | web client | unknown | No gnutls Scheme bindings in ports; 1 HTTP hop then TLS fail |
18. Results
Executed 2026-06-14 against live /ouroboros/a.
| Client | Default limit | Server-side hops | Error class | Verified |
|---|---|---|---|---|
| curl | 50 | 51 | error 47 | Yes |
| Python requests | 30 | 31 | TooManyRedirects |
Yes |
| Python urllib | ~10 | 9 | HTTPError (loop detect) |
Yes |
| Node fetch (undici) | 20 | 21 | TypeError |
Yes |
| Deno fetch | 20 | 21 | TypeError (explicit msg) |
Yes |
| Go net/http | 10 | 10 | *url.Error |
Yes |
| Perl LWP | 7 | 8 | silent 301 | Yes |
| Erlang httpc | ~5 (auto) | 106 (manual) | -- | Yes |
| Elixir (OTP httpc) | ~5 (auto) | 100 (manual) | -- | Yes |
| Java HttpURLConnection | 20 (auto) | 505 (manual via bb) | -- | Yes |
| Ruby net/http | no auto-follow | 101 (manual) | -- | Yes |
| Crystal http::Client | no auto-follow | 101 (manual) | -- | Yes |
| Racket net/url | no auto-follow | 100 (manual) | -- | Yes |
Server-side log verification:
grep 'wal-sh-test' logs/wal.sh/https/access.log \ | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn
Server-side counts match client-side reports. The bb count (505) is from multiple test runs during development.
19. Analysis
The auto-follow limits cluster into three tiers:
| Tier | Limit | Clients |
|---|---|---|
| Conservative | 7–10 | Perl LWP (7), Go (10), Rust (10), Erlang (~5) |
| Standard | 20–30 | Node (20), Deno (20), Java (20), Python requests (30) |
| Permissive | 50 | curl (50), .NET (50) |
Three clients have no auto-follow at all: Ruby, Crystal, Racket. The caller must implement redirect handling explicitly. This is a design choice, not a missing feature – it forces the caller to decide on loop detection.
The ouroboros canaries serve as a cross-runtime property test: every HTTP client that follows redirects must eventually give up. The when varies (7–50 hops), the how varies (exception, silent truncation, error code), but the that is universal.
Implications for the bot compliance spec (C6) (C6, deferred to v1.4): the six-language crawler inherits each library's default. A redirect chain of 8 hops works for Python but fails for Perl. Normalizing the limit across languages is a v1.4 requirement.