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. Janet (raw net/socket)
- 18. .NET (HttpClient)
- 19. Fennel (Lua 5.4)
- 20. Zig (std.http)
- 21. Not tested
- 22. Results
- 23. 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) |
| Rust | 1.94.0 (reqwest 0.12.28) |
| Haskell | GHC 9.10.3 (http-client 0.7.19) |
| Julia | 1.10.5 (HTTP.jl, --compiled-modules=no) |
| Janet | 1.41.2 (net/socket, raw TCP) |
| Fennel | 1.6.1 on Lua 5.4 (no luasocket) |
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.
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's (web client) does not follow redirects automatically –
the caller handles 3xx explicitly. Tested against the
ouroboros canary:
(use-modules (web client) (web response) (web uri)) (let loop ((u "http://wal.sh/ouroboros/a") (h 0)) (if (>= h 100) (format #t "guile3: ~a hops (limit hit)~%" h) (catch #t (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+ h)) (format #t "guile3: ~a hops, status ~a~%" h code)))))) (lambda (key . args) (format #t "guile3: error at hop ~a: ~a~%" h key)))))
Guile's (web client) follows redirects without a built-in limit –
the manual loop ran all 100 hops. The (web client) module has no
auto-follow mode; the caller always handles 3xx explicitly.
17. Janet (raw net/socket)
Janet 1.41.2 has net/connect but no HTTP client module. Redirect following
via raw TCP:
(defn http-head [url]
(def parsed (peg/match ~{:main (* "http://" (<- (to "/")) (<- (any 1)))} url))
(def host (get parsed 0))
(def path (get parsed 1))
(def conn (net/connect host "80"))
(net/write conn (string "HEAD " path " HTTP/1.0\r\nHost: " host
"\r\nUser-Agent: wal-sh-test/janet\r\nConnection: close\r\n\r\n"))
(def resp (string (net/read conn 4096)))
(net/close conn)
(def status-match (peg/match ~(* "HTTP/" (some :d) "." (some :d) " " (<- (some :d))) resp))
(def status (scan-number (get status-match 0)))
(def loc-match (peg/match ~(* (thru "ocation: ") (<- (to "\r"))) resp))
(def location (if loc-match (get loc-match 0) nil))
{:status status :location location})
(var url "http://wal.sh/ouroboros/a")
(var hops 0)
(while (< hops 100)
(def resp (http-head url))
(if (and (>= (resp :status) 300) (< (resp :status) 400) (resp :location))
(do (set url (resp :location)) (++ hops))
(do (printf "janet: %d hops, status %d" hops (resp :status)) (break))))
(when (>= hops 100) (printf "janet: %d hops (limit hit)" hops))
No auto-follow; no built-in limit. Janet's PEG parser makes raw HTTP response parsing concise.
18. .NET (HttpClient)
using System.Net.Http; var handler = new HttpClientHandler(); var client = new HttpClient(handler); client.DefaultRequestHeaders.Add("User-Agent", "wal-sh-test/dotnet"); try { var resp = await client.SendAsync( new HttpRequestMessage(HttpMethod.Head, "http://wal.sh/ouroboros/a")); Console.WriteLine($"dotnet: status {(int)resp.StatusCode}"); } catch (HttpRequestException ex) { Console.WriteLine($"dotnet: {ex.GetType().Name}: {ex.Message}"); }
Default limit: 50. HttpClientHandler.MaxAutomaticRedirections defaults
to 50. The 51st response (a 301) is returned to the caller.
19. Fennel (Lua 5.4)
Fennel 1.6.1 compiles to Lua 5.4, which has no socket library installed
(luasocket not in pkg). Not testable without pkg install luasocket.
20. Zig (std.http)
Zig 0.15.2's std.http.Client defaults to 3 max redirects – the lowest of
any client tested. The API changed significantly between 0.13 and 0.15
(open → request + sendBodiless + receiveHead).
const std = @import("std");
const http = std.http;
pub fn main() !void {
var client: http.Client = .{ .allocator = std.heap.page_allocator };
defer client.deinit();
const uri = try std.Uri.parse("http://wal.sh/ouroboros/a");
var redirect_buf: [8192]u8 = undefined;
var req = try client.request(.HEAD, uri, .{
.headers = .{ .user_agent = .{ .override = "wal-sh-test/zig" } },
});
defer req.deinit();
try req.sendBodiless();
const resp = req.receiveHead(&redirect_buf) catch |err| {
std.debug.print("zig: error {} (hit redirect limit of 3)\n", .{err});
return;
};
std.debug.print("zig: status {d}\n", .{@intFromEnum(resp.head.status)});
}
Default limit: 3. After 3 internal redirects, the 4th 301 is returned as-is. This is even more conservative than Go (10) or Perl LWP (7).
21. Not tested
| Language | Version on box | HTTP library | Documented limit | Why not tested |
|---|---|---|---|---|
| Rust | 1.94.0 | reqwest 0.12.28 | 10 | Tested — throws reqwest::Error |
| Haskell | GHC 9.10.3 | http-client 0.7.19 | 10 | Tested — throws TooManyRedirects |
| Kotlin | 2.3.0 (JVM 21) | OkHttp/ktor | ~20 (JVM default) | JVM HTTP behavior same as Clojure |
| .NET | 9.0.115 | HttpClient | 50 | Tested — 51 server-side hops confirms default |
| Julia | 1.10.5 | HTTP.jl | 10 | Tested — throws TooManyRedirectsError (precompile crashes on FreeBSD, use --compiled-modules=no) |
| SBCL | 2.5.7 | dexador/drakma | varies | Package install required |
| ECL | 24.5.10 | -- | -- | No HTTP library |
| Zig | 0.15.2 | std.http | 3 | Tested — lowest default of any client |
| CHICKEN | 5.4.0 | http-client egg | unknown | Egg install required |
| Guile | 3.0.10 | web client | no auto-follow | Tested via HTTP (ouroboros HTTPS exemption) |
22. Results
Executed 2026-06-14 on FreeBSD 15.0-RELEASE against live /ouroboros/a
(HTTP for Guile, HTTPS for all others). Server-side hop counts from
gmake logs match client-side reports for all 15 clients.
| 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 |
| Guile web client | no auto-follow | 100 (manual, HTTP) | -- | Yes |
| Zig std.http | 3 | 1 (server-side) | returns 301 | Yes |
| Janet net/socket | no auto-follow | 100 (manual, HTTP) | -- | Yes |
| .NET HttpClient | 50 | 51 | returns 301 (silent) | Yes |
| Rust reqwest | 10 | -- | reqwest::Error |
Yes |
| Haskell http-client | 10 | -- | TooManyRedirects |
Yes |
| Julia HTTP.jl | 10 | -- | TooManyRedirectsError |
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.
23. Analysis
The auto-follow limits cluster into three tiers:
| Tier | Limit | Clients |
|---|---|---|
| Conservative | 7–10 | Perl LWP (7), Go (10), Rust reqwest (10), Haskell (10), Julia (10) |
| Standard | 20–30 | Node (20), Deno (20), Java (20), Python requests (30) |
| Permissive | 50 | curl (50), .NET (50, silent – no exception on limit) |
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.