Record why redirect destinations can differ

How-to Last tested 25 October 2026

A redirect destination is an observation made with a particular request context, not a permanent property of a short URL. Record each response, the next location it supplied and the context used to request it; another user agent, language, cookie state or visit can produce a different chain. Reading an address locally is passive, while asking a shortener or preview service about it contacts that service.

Record what happened before explaining why

A useful record begins with the exact input, request method, time, client context and observed responses. It distinguishes a redirect supplied by the server from a destination guessed from text. HTTP defines redirect responses and the Location field that identifies their next reference1. A terminal response means only that this recorder stopped following its recognized HTTP redirects; it does not mean a browser would stop all navigation there.

The example below uses one reserved origin, http://redirect.example.test, and an explicitly supplied local transport. It is not an arbitrary link expander. Every result names the requested URL, response status, Location value and context for that hop. The local server also supplies a visit counter, which is test instrumentation; an ordinary service need not reveal its routing state.

Do not turn a single final address into “where this link always goes.” A difference between two traces is evidence of a difference, but it does not identify the cause unless the conditions are controlled. Here the server rules are original and known, so the experiment can attribute each selected destination to a particular input. Unknown real services require more cautious wording.

The complete recorder

Save the following original file as redirect_record.py. The reproduction instructions explain the controlled transport and local tests. The recorder refuses other origins, unsupported characters, queries and fragments; it never supplies a general-purpose network client. The fixed context contains only synthetic values, and the test server receives those same fields on every hop.

# SPDX-License-Identifier: MIT
"""Record controlled HTTP redirects through an explicitly supplied transport."""
from datetime import datetime, timezone
from urllib.parse import urlsplit


def checked_fixture_url(text):
    if not isinstance(text, str) or len(text) > 2048:
        raise ValueError("Fixture URL type or length")
    if "\\" in text or any(ord(c) < 33 or ord(c) > 126 for c in text):
        raise ValueError("Unsupported fixture URL")
    parsed = urlsplit(text)
    if (parsed.scheme != "http" or parsed.netloc != "redirect.example.test"
            or parsed.fragment or parsed.query or parsed.username is not None):
        raise ValueError("Only the reserved fixture origin is accepted")
    return parsed.path or "/"


async def record_chain(start, context, exchange, *, max_hops=4):
    if set(context) != {"user_agent", "language", "cookie"}:
        raise ValueError("Declare exactly the three request-context fields")
    if any(not isinstance(v, str) or len(v) > 256
           or any(ord(c) < 32 or ord(c) > 126 for c in v)
           for v in context.values()):
        raise ValueError("Unsupported context value")
    headers = {"User-Agent": context["user_agent"],
               "Accept-Language": context["language"]}
    if context["cookie"]:
        headers["Cookie"] = context["cookie"]
    current = start
    records = []
    for hop in range(max_hops + 1):
        path = checked_fixture_url(current)
        at = datetime.now(timezone.utc).isoformat()
        status, response_headers = await exchange(path, headers.copy())
        location = response_headers.get("location")
        records.append({
            "hop": hop, "requested_url": current, "method": "GET",
            "at_utc": at, "request_context": context.copy(),
            "status": status, "location": location,
            "fixture_visit": response_headers.get("x-fixture-visit"),
        })
        if status not in {301, 302, 303, 307, 308}:
            return {"stop": "terminal_response", "hops": records}
        if hop == max_hops:
            return {"stop": "hop_limit", "hops": records}
        if not location:
            return {"stop": "missing_location", "hops": records}
        checked_fixture_url(location)
        current = location
    raise AssertionError("Unreachable")

record_chain starts by checking the declared context fields, then makes one explicit exchange at a time. It stops on a non-redirect response, a missing Location or its hop limit. Each following URL must pass the same reserved-origin check. The callback is a deliberate boundary: in this package it can connect only to the temporary local server, and a process-level guard rejects any other connection.

Four changes, four different destinations

The first pair changes only the synthetic User-Agent text. The local rule sends the desktop variant to /dest/desktop and the mobile variant to /dest/mobile. Those names describe branches in this example, not measurements of a phone or desktop browser. A server can use client headers as routing inputs; the text of a User-Agent is not a reliable statement of the person or software behind it.

The second pair changes Accept-Language from en to fr. Its two destinations are /dest/english and /dest/french. MDN describes request headers as inputs to server-driven content negotiation, with server-specific selection rules2. The local example expresses its language choice as a redirect. Another server could keep the same URL and return a different representation instead, so a changed page is not automatically a changed redirect chain.

Controlled changeFirst destinationSecond destination
User-Agent branch/dest/desktop/dest/mobile
Language preference/dest/english/dest/french
Synthetic cookie absent/present/dest/new/dest/returning
Same context, next visit/dest/visit-1/dest/visit-2

The third pair supplies no Cookie header, then supplies the synthetic value segment=returning. The rule chooses the new-visitor or returning-visitor destination accordingly. The recorder is not a browser cookie jar: it does not learn cookies from responses, apply cookie scope rules or restore a user’s session. It sends only the explicitly declared test value, which is copied into each hop record so the difference cannot disappear from the evidence.

The fourth pair changes nothing in the request context. The server increments its counter for /count, returning the first and second visit destinations. This demonstrates that matching client fields do not establish matching server state. A report without request time and sequence could wrongly suggest that one of those two observations was an error.

Keep the context with every hop

The ninth observation starts at /chain, moves to /ua, then ends at the mobile branch. All three requests retain the declared User-Agent, language and synthetic cookie. The full observation record contains the corresponding server-received headers, allowing the recorded context to be checked against what the local server actually received.

Each of the other eight observations makes two requests, for 19 local requests in total. The process guard measured zero system-DNS attempts and zero non-fixture connection attempts, copied those counters into the record and asserted both were zero. The earlier record is retained, but its overwritten zero fields are not measurements. Actual local bind details are omitted from the published record; reserved example names describe the fixture’s logical origin. This was a command-line HTTP test, not a browser test or an observation of a real shortener.

The recorder does not execute JavaScript, follow an HTML refresh or reproduce cached browser state. MDN distinguishes HTTP redirects from other redirection mechanisms3. That distinction matters when a browser and a header-only report seem to disagree: they may be observing different mechanisms. It does not justify opening an unfamiliar target in an everyday browser to settle the difference.

Know when a preview makes contact

Locally reading the visible text, parsed hostname or encoded characters does not ask the shortener for its stored destination. The URL inspector supports that limited kind of inspection. An opaque path on a shortener normally does not contain enough information to reconstruct its current stored destination from the characters alone.

Provider previews are a different operation. Bitly’s Trust Center documents its Link Checker for previewing a destination4. The is.gd FAQ documents a dash-suffix preview and its automatic-preview setting5. These are provider-documented features, not universal URL syntax. They were read for this article, not used on any link, and the examples here do not contain working shortener addresses.

Requesting such a preview still sends information to its provider. Do not describe it as offline, invisible or guaranteed to leave the destination untouched unless that exact behavior has been established for the current service. A displayed destination is also not a safety verdict. The submission-privacy guide and lookup-versus-submission comparison explain the disclosure questions to resolve before sharing an address.

Write an observation that survives comparison

Prefer a bounded statement such as: this controlled request, at this time, received these HTTP responses with these declared fields. Preserve the original chain when comparing a later observation; do not overwrite it with a single “current” destination. Mark unavailable context as unknown rather than reconstructing a cookie, location or client setting that was never recorded.

When the chain differs, first compare the fields you actually have: method, time, client headers, cookie state and hop sequence. A missing field is a limit on explanation. For an unfamiliar real link, the practical result may remain “destination not established without contacting another service.” The local experiment explains why certainty is limited; it is not an instruction to bypass a warning or expand a suspicious link.

Not covered on this page

Sources

  1. HTTP Semantics. RFC 9110, June 2022; sections 10.2.2 and 15.4. ↩

  2. MDN HTTP content negotiation. Current documentation, read 2026-10-25. ↩

  3. MDN HTTP redirections. Current documentation, read 2026-10-25. ↩

  4. Bitly Trust Center: Link Checker documentation. Current provider documentation, read 2026-10-25; preview not used. ↩

  5. is.gd FAQ: preview documentation. Current provider documentation, read 2026-10-25; preview not used. ↩

How we test: methodology. Found a mistake? Corrections.