12 min read

Mapping HTTP Status Codes to Health Score Penalties

Status codes look like the easiest thing in an audit to score: the crawler already has them, they are standardised, and everyone agrees a 500 is worse than a 200. The difficulty is that a status code describes a request rather than a page, and the same code means very different things depending on how the crawler arrived at the URL. This page is part of Health Score Reference Tables and gives a penalty table with the context rules that make it usable.

Penalty per status class A horizontal bar chart of the indexability penalty applied to each status class. A 5xx server error costs the full 100 penalty points. A 404 costs 100 as well but only where the URL is declared in a sitemap. A 403 costs 70 because it may be intentional. A 302 costs 25 as a temporary signal that is often permanent in practice. A 301 costs 10 for the hop it adds. A 200 costs nothing. INDEXABILITY PENALTY BY STATUS CLASS 5xx — server error 100 404 — declared in sitemap 100 403 — forbidden 70 404 — not declared anywhere 40 302 — temporary redirect 25 301 — permanent redirect 10 200 — OK 0

The penalty table

Penalties are expressed on a 0-100 indexability scale, where 100 is a total loss of indexability for that URL. They are combined into the composite through the indexability weight described in designing custom health score algorithms.

Status Discovery source Penalty Rationale
200 any 0 The page resolves; other dimensions score it
204 / 206 any 20 Resolves, but carries no indexable body
301 any 10 per hop past the first One hop is normal; a chain is the finding
302 / 307 any 25 Temporary signalling on a permanent move
304 any 0 A cache revalidation, not a page state
400 any 60 Malformed request — usually a bad internal link
401 any 30 Expected on gated paths; a finding only in public scope
403 any 70 Often intentional, occasionally a misconfiguration
404 / 410 declared in sitemap 100 The site asserts the page exists and it does not
404 / 410 internal link only 60 A broken internal link
404 / 410 external or historic 20 Expected attrition
429 any 0 for the page, flag the crawl A crawler problem, not a site problem
5xx any 100 The page is unavailable to everyone
timeout any 90 Indistinguishable from unavailable to a visitor

The 429 row is the one most often got wrong. A rate-limit response says the crawler asked too fast, not that the page is unhealthy — scoring it as a page defect penalises the site for the audit's own behaviour, and the correct response is to fix the rate limiting and re-crawl.

The same 404, three penalties A question card asks where the URL that returned a 404 was discovered, with three outcomes. A URL declared in the sitemap is a page the site asserts should exist, so the penalty is full. A URL reached only by an internal link is a broken link, which is a real but lesser fault. A URL found only in an external referrer or an old crawl is expected attrition and carries a small penalty or none. Where did the URL that returned this 404 come from? declared in the sitemap Full penalty the site asserts this page exists, and it does not an internal link only Partial penalty a broken link — real, but a smaller fault an external or historic source Minimal penalty expected attrition, not a defect in the site A status code alone cannot be scored. The same 404 is a broken promise, a broken link, or nothing at all depending entirely on how the crawler arrived at the URL.

Implementation

#!/usr/bin/env python3
# /opt/audit/scoring/status_penalty.py
# Penalties are a function of (status, discovery source, hop count) — never
# of the status alone.
from __future__ import annotations
from dataclasses import dataclass

SITEMAP, INTERNAL, EXTERNAL = "sitemap", "internal_link", "external"


@dataclass(frozen=True)
class Observation:
    status: int | None      # None means the request timed out
    source: str             # SITEMAP | INTERNAL | EXTERNAL
    hops: int = 0           # redirect hops before the final status
    in_public_scope: bool = True


def penalty(o: Observation) -> tuple[float, str]:
    """Return (penalty, reason). The reason is emitted with the score so a
    number can always be traced back to the rule that produced it."""
    if o.status is None:
        return 90.0, "timeout"
    if o.status == 429:
        # Not a page defect: the crawl was too aggressive. Flag the run.
        return 0.0, "rate_limited_crawler_fault"
    if 500 <= o.status < 600:
        return 100.0, "server_error"
    if o.status in (404, 410):
        return ({SITEMAP: (100.0, "gone_but_declared"),
                 INTERNAL: (60.0, "broken_internal_link")}
                .get(o.source, (20.0, "expected_attrition")))
    if o.status == 403:
        return 70.0, "forbidden"
    if o.status == 401:
        return (30.0, "auth_required") if o.in_public_scope else (0.0, "gated_expected")
    if o.status == 400:
        return 60.0, "bad_request"
    if o.status in (301, 308):
        extra = max(0, o.hops - 1)
        return 10.0 * (1 + extra), f"permanent_redirect_{o.hops}_hops"
    if o.status in (302, 307):
        return 25.0, "temporary_redirect"
    if o.status in (204, 206):
        return 20.0, "no_indexable_body"
    return 0.0, "ok"

The parts that matter:

  • source is a required field, not an optional refinement. A scorer that accepts a status without knowing where the URL came from cannot distinguish a broken promise from expected attrition, and those two deserve a five-fold difference in penalty.
  • hops scales the redirect penalty. A single 301 is ordinary site maintenance; four stacked ones is the finding described in fixing broken redirect chains.
  • 429 returns zero and a distinguishing reason. The reason string is what lets a downstream check count rate-limited responses and fail the crawl rather than the site.
  • Every branch returns a reason. A penalty without a traceable rule is a number nobody can argue with or correct.

Verification and smoke test

/opt/audit/.venv/bin/python3 - <<'PY'
from status_penalty import Observation, penalty, SITEMAP, INTERNAL, EXTERNAL

# 1. The same status scores differently by source.
p_sitemap, _ = penalty(Observation(404, SITEMAP))
p_link, _    = penalty(Observation(404, INTERNAL))
p_ext, _     = penalty(Observation(404, EXTERNAL))
assert p_sitemap > p_link > p_ext, (p_sitemap, p_link, p_ext)
print(f"PASS: 404 scores {p_sitemap}/{p_link}/{p_ext} by source")

# 2. Redirect penalty grows with hop count.
assert penalty(Observation(301, INTERNAL, hops=1))[0] == 10.0
assert penalty(Observation(301, INTERNAL, hops=4))[0] == 40.0
print("PASS: redirect penalty scales with hops")

# 3. A rate limit is never charged to the page.
pen, reason = penalty(Observation(429, INTERNAL))
assert pen == 0.0 and "crawler" in reason
print(f"PASS: 429 -> {pen} ({reason})")

# 4. Every penalty is bounded and carries a reason.
for s in (200, 204, 301, 302, 400, 401, 403, 404, 500, 503, None):
    p, r = penalty(Observation(s, INTERNAL, hops=1))
    assert 0.0 <= p <= 100.0 and r
print("PASS: all penalties bounded, all carry a reason")
PY

Expected output is four PASS lines. The first is the assertion worth keeping permanently, because it encodes the whole idea of the table: a status code is not scoreable on its own.

Failure modes

Three ways status scoring misleads Three rows. Scoring the status without its discovery source treats sitemap 404s and stale external links identically. Treating every 3xx equally ignores that a chain of three costs far more than a single hop. And scoring the first status rather than the final one penalises a page for its redirect while ignoring what the redirect actually resolves to. MISTAKE WHAT IT PRODUCES FIX Status without its source sitemap 404s and stale external links score alike Context is the penalty the code alone says nothing about whether it is a defect Score the pair status plus discovery source, never status alone Every 3xx scored equally a single hop and a chain of four cost the same Depth is the cost a redirect is fine, a chain of redirects is the finding Scale with hop count penalty grows per hop past the first The first status scored a redirecting page is penalised for redirecting The final status is the page what the URL resolves to is what a visitor actually receives Score both separately the hop as a redirect finding, the destination as the page All three come from treating a status code as a self-contained fact. It is a fact about a request, and scoring needs a fact about a page.

Every 404 in the report is high severity

The discovery source is missing from the crawl export, so every 404 falls into the default branch. Confirm the crawler records how each URL was discovered; without it, no status-based scoring can be more than approximate, and the sensible interim is to score all 404s at the internal-link level rather than the sitemap level.

A rate-limited crawl reports a collapsed health score

429s were scored as page defects. The reason string exists to catch this: count rate_limited_crawler_fault and fail the run when it exceeds a small fraction of requests, rather than publishing a score that describes the crawler's manners.

Redirect penalties dominate the indexability subscore

Hop counts are being taken from the raw request chain including scheme upgrades, so almost every URL shows at least two hops. Count hops after normalising the scheme and trailing slash, or every http:// seed is penalised for a redirect that is the site working correctly.

FAQ

Why does the same 404 get three different penalties?

Because a status code describes a request and a score has to describe a page. A 404 on a URL the sitemap declares is the site asserting a page exists that does not — a broken promise, and the most serious form. A 404 reached only by an internal link is a broken link, which is a real fault of lesser consequence. A 404 on a URL found only in an old external referrer is ordinary attrition and is not a defect at all. Scoring the code without the source collapses all three into one number.

Should a 429 count against the health score?

No. A rate-limit response says the crawler asked too fast, not that the page is unhealthy, so scoring it as a page defect penalises the site for the audit own behaviour. Return a zero penalty with a distinguishing reason, count those reasons across the run, and fail the crawl when they exceed a small fraction of requests — the correct remedy is throttling and a re-crawl, not a published score describing the crawler manners.

Should the first status or the final status be scored?

Both, separately. The final status describes what a visitor actually receives and is what the page score should reflect. The hops taken to reach it are a distinct finding about redirect hygiene, penalised by depth rather than by the code. Scoring only the first status penalises a correctly redirecting page for redirecting; scoring only the final one hides a four-hop chain behind a healthy 200.