HTTP Redirect Limits: One Ouroboros, Six Languages

Table of Contents

1. The experiment

The TLA+ redirect model documents two ouroboros canaries on wal.sh:

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.