Skip to content

Redirects

HTTPC follows HTTP redirects automatically by default (up to 10 times) and records the complete redirect chain. This page covers following control, hop limits, chain tracking, status-code method semantics, cross-origin credential stripping, circular redirect detection, domain whitelisting, and the interaction with SSRF protection.

Default Behavior

Without any configuration, the client follows redirects automatically until the final response is reached or the hop limit is hit:

go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://httpbin.org/redirect/2")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(result.StatusCode())        // 200 (status of the final response)
    fmt.Println(result.Meta.RedirectCount)  // 2 (redirects actually followed)
}

The engine automatically follows the five redirect status codes 301/302/303/307/308, with method semantics per the HTTP specification:

StatusMethod handling
301 / 302 / 303May rewrite POST as GET (allowed by the spec)
307 / 308Resent with the original method and body

Status-Code Semantics (301/302/303/307/308)

The five status codes differ in method rewriting and body handling:

StatusMeaningMethod handlingBodyTypical use
301Moved permanentlyGET/HEAD unchanged; POST and the like rewritten to GETDroppedDomain migration, URL normalization
302Found (temporary)Same as 301 (follows the de-facto standard behavior)DroppedTemporary jumps, post-login forwarding
303See OtherAlways rewritten to GETDroppedRedirecting to a result page after POST
307Temporary redirectOriginal method keptResentTemporary jumps that must resend the body
308Permanent redirectOriginal method keptResentPermanent jumps that must resend the body

307/308 with a body is not followed automatically

307/308 require replaying the request body with the original method. The underlying net/http only follows when the request body is replayable (GetBody set), and HTTPC does not set that function when building requests — so a request carrying a non-empty body that receives a 307/308 is not followed automatically; the 3xx response is returned as-is (no error). When you do need to follow such jumps, disable following with WithFollowRedirects(false) and loop manually, or have the server use 302/303 instead.

300/304 and other 3xx are outside the follow set

Even with a Location header present, the engine only follows 301/302/303/307/308; 300 (Multiple Choices), 304 (Not Modified), and other 3xx responses are returned as-is for the caller to handle. Note that Result.IsRedirect() tests the whole 300-399 range and is unrelated to automatic following.

Exceeding the limit fails the request

When the redirect count exceeds MaxRedirects, the request ends with an error (message like stopped after 3 redirects) — it never loops forever. The valid range for MaxRedirects is 0-50; out-of-range values fail config validation.

Controlling Following

Redirect configuration lives on three levels, from widest to narrowest scope:

LevelConfigurationScope
Client levelConfig.Defaults.FollowRedirects / MaxRedirectsAll requests of the whole client
Request levelWithFollowRedirects(bool) / WithMaxRedirects(n)A single request, overriding the client configuration
PresetSecureConfig(), MinimalConfig() (both set FollowRedirects=false)Security/minimal scenarios default to not following

Client Level

Config.Defaults (RequestDefaults) sets the redirect policy for the entire client:

go
cfg := httpc.DefaultConfig()
cfg.Defaults.FollowRedirects = true  // default: follow
cfg.Defaults.MaxRedirects = 5        // default: 10

client, err := httpc.New(cfg)

Per Request

WithFollowRedirects / WithMaxRedirects override the client configuration for a single request:

go
// Disable following for just this request and take the 3xx response directly
result, err := httpc.Get("https://httpbin.org/redirect/1",
    httpc.WithFollowRedirects(false),
)
if result.IsRedirect() {
    fmt.Println(result.Response.Headers.Get("Location")) // the redirect target
}

// Limit following to 3 hops for just this request
result, err = httpc.Get(url, httpc.WithMaxRedirects(3))

MaxRedirects(0) is not "disable"

0 is the "unset" sentinel — the engine falls back to the default of 10 instead of disabling redirects. To disable following, use WithFollowRedirects(false) or Config.Defaults.FollowRedirects = false.

SecureConfig disables redirects by default

The SecureConfig() preset sets FollowRedirects to false, preventing requests from being led to internal addresses via redirects (redirect-based SSRF). See SSRF Protection for the security details.

Tracking the Redirect Chain

Result.Meta records redirect information for every request:

FieldDescription
Meta.RedirectCountNumber of redirects actually followed
Meta.RedirectChainSequence of URLs visited during the redirects

The precise semantics of both fields:

  • RedirectChain records the source URL of each hop: the first entry is the initial request URL, followed by each intermediate URL in order; the final target URL is not in the chain. When you need the final address, read result.Request.URL (the final request URL once following completes).
  • RedirectCount always equals len(RedirectChain).
go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://httpbin.org/redirect/3")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Followed %d redirects\n", result.Meta.RedirectCount)
    for i, u := range result.Meta.RedirectChain {
        fmt.Printf("  %d. %s\n", i+1, u)
    }
    fmt.Println("Final destination:", result.Request.URL)
    // Output:
    //   1. https://httpbin.org/redirect/3
    //   2. https://httpbin.org/redirect/2
    //   3. https://httpbin.org/redirect/1
    // Final destination: https://httpbin.org/get
}

Cross-Origin Credential Stripping

While following redirects, the engine checks the target hostname at every hop: when it differs from the initial request's hostname (a cross-origin jump), sensitive request headers are removed automatically to keep credentials from leaking to the redirect target:

HeaderTarget host = initial request hostTarget host ≠ initial request host
AuthorizationKeptRemoved
Proxy-AuthorizationKeptRemoved
CookieKeptRemoved
Other custom headersKeptKept

Matching details:

  • The comparison baseline is the initial request's hostname (the first hop's via[0]), not the previous hop. api.example.com → www.example.com counts as cross-origin (exact-hostname comparison — a different subdomain counts); A → B → A returning to the initial host is not stripped.
  • Stripping is independent of the cookie jar: even with EnableCookies off, a manually set Cookie request header is still removed on cross-origin jumps.

No extra handling is needed alongside WithBasicAuth / WithBearerToken — the token only goes to the original host and is not forwarded to third-party domains by the jumps.

Circular Redirect Detection

Beyond the hop limit, the engine also detects circular redirects (a jump target that has already appeared earlier in the chain). On a hit, the request fails immediately instead of waiting for the hop budget to run out:

text
A → B → A     circular: fails with circular redirect detected: A
A → A → A     consecutive identical URLs: not counted as circular (the server may return a different response each time)

Circular detection and the MaxRedirects hop limit complement each other: the hop limit catches every loop, while circular detection spots "obviously going in circles" chains early and saves pointless requests.

Redirect Error Classification

When following is refused (limit exceeded, circular, whitelist, SSRF block), the request ends with a ClientError whose Type is uniformly ErrorTypeValidation:

Underlying error messageMessage fieldTrigger
stopped after N redirectsredirect limit exceededThe hop count reached MaxRedirects (or the default of 10)
circular redirect detected: <URL>circular redirect detectedThe target URL already appeared in the jump chain
redirect blocked by whitelist: ...redirect blocked by policyThe target domain is not in RedirectWhitelist
redirect blocked: ...redirect blocked by policyThe target host was blocked by SSRF protection
go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    cfg := httpc.DefaultConfig()
    cfg.Defaults.MaxRedirects = 2 // only allow 2 follows

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // /redirect/5 needs 5 follows and will necessarily exceed the limit
    _, err = client.Get("https://httpbin.org/redirect/5")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) && clientErr.Type == httpc.ErrorTypeValidation {
            fmt.Println("Redirect refused:", clientErr.Message)
            // Output: Redirect refused: redirect limit exceeded
        }
    }
}

SSRF validation of redirect targets also includes two hard rules: only http/https schemes are allowed (a Location with ftp:// or a custom scheme is rejected) and the target host must not be empty. No DNS resolution happens at validation time — full IP validation and DNS-rebinding protection run at connection time in the dialer (see SSRF Protection).

Domain Whitelist

Security.RedirectWhitelist restricts redirect targets to trusted domains, defending against open-redirect attacks:

go
cfg := httpc.DefaultConfig()
cfg.Security.RedirectWhitelist = []string{
    "api.example.com",
    "*.cdn.example.com", // wildcard: matches strict subdomains, not the bare domain
}

client, err := httpc.New(cfg)

Matching rule details:

  • Exact match: api.example.com matches only itself.
  • Wildcard: *.cdn.example.com matches strict subdomains (e.g. img.cdn.example.com) but not the bare domain cdn.example.com; to allow both, list both.
  • The comparison target is the Location target's hostname (without port or scheme), normalized before comparison: surrounding whitespace ignored, case-insensitive.

Once set, requests redirected to domains outside the whitelist are rejected; redirect targets also pass SSRF IP validation. When handling user-supplied URLs, combine this with SecureConfig or a whitelist.

Handling Redirects Manually

When you need per-hop inspection, conditional following, or custom logging, disable automatic following and loop yourself. Two things to note: Location may be a relative address that must be resolved against the current URL into an absolute one; a manual loop does not go through whitelist checks, but the connection-layer SSRF IP validation still applies when each hop establishes its connection.

go
package main

import (
    "fmt"
    "log"
    "net/url"

    "github.com/cybergodev/httpc"
)

func main() {
    cfg := httpc.DefaultConfig()
    cfg.Defaults.FollowRedirects = false

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    currentURL := "https://httpbin.org/redirect/3"
    base, err := url.Parse(currentURL)
    if err != nil {
        log.Fatal(err)
    }

    for i := 0; i < 5; i++ {
        result, err := client.Get(currentURL)
        if err != nil {
            log.Fatal(err)
        }
        if !result.IsRedirect() {
            fmt.Println("Reached final destination:", currentURL)
            break
        }

        location := result.Response.Headers.Get("Location")
        if location == "" {
            fmt.Println("Redirect response has no Location header, stopping")
            break
        }

        // Resolve a relative address against the current URL
        next, err := base.Parse(location)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("Hop %d: %s\n", i+1, next.String())

        currentURL = next.String()
        base = next
    }
}

Security responsibility in manual loops

The whitelist and redirect-target pre-checks that apply during automatic following do not apply to manual loops. When processing Location from untrusted sources, validate the target domain yourself inside the loop (or reuse the whitelist logic); connection-layer SSRF validation still provides a backstop, but domain-level control is up to you.

Troubleshooting

SymptomCauseSolution
POST receives 307/308 and is not followedThe body is not replayable (GetBody unset); per the spec it is not followed automaticallyLoop manually, or have the server use 302/303
WithMaxRedirects(0) did not disable following0 is the "unset" sentinel and falls back to the default of 10Use WithFollowRedirects(false)
A 300/399 carrying Location did not jumpThe engine only follows 301/302/303/307/308Handle it yourself with IsRedirect() + Location
Query parameters "lost" after a jumpThe target query string is decided entirely by Location; the client does not merge the original request's parametersHave the server include the needed parameters in Location
Want to know which domain you landed onRedirectChain does not include the final URLRead result.Request.URL
Request returns 401 after a cross-origin jumpAuthorization/Cookie were stripped cross-origin (by design)Re-authenticate against the new domain, or have the server redirect within the same domain

Next Steps