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)
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 (openrequest + 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.