DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

How a BGP UPDATE Changes State: Reading Withdrawal and Announcement as Code

Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-02/ — part of the free Protocol Lab series.

This post is part of Protocol in Code, a free series that reads network protocols not as configuration examples but as logic — inputs, state, and branches you can step through. If you'd rather read the whole thing yourself, the source lives at github.com/pathvector-studio/protocol-in-code.

New to this? If you can run commands but haven't yet built up hands-on intuition for how these protocols behave, start with the companion Protocol Lab series first: github.com/pathvector-studio/protocol-lab. It gets your hands dirty before you start reading the internals as code.

The question to hold in your head

Here's the one thing to keep turning over as you read:

How does a BGP UPDATE change routing state, and why is route withdrawal different from session failure?

Most explanations of BGP UPDATE treat it as a packet format — here are the withdrawn routes, here are the NLRI, here are the path attributes, memorize the byte layout. That framing tells you what an UPDATE contains. It doesn't tell you what an UPDATE does.

The move in this session is to stop asking "how do I decode this packet?" and start asking:

  • What disappeared?
  • What appeared?
  • Which attributes arrived with the new path?
  • Did the route leave because of a withdrawal, or because the session itself died?

Those last two are the ones people conflate, and the code makes the difference impossible to fudge. Let's read it.

The shape of an UPDATE

The toy model for this session is a single file: src/protocol_in_code/bgp/update.py. Start with the data.

A path attribute set is exactly what travels with an advertised route:

@dataclass(frozen=True)
class PathAttributes:
    next_hop: str
    as_path: tuple[int, ...]
    origin: str
    local_pref: int = 100
Enter fullscreen mode Exit fullscreen mode

next_hop, as_path, origin, local_pref — this is the policy and path context. Note that it's frozen=True: an announcement carries an immutable description of how to reach a prefix. Hold onto that; it's the reason announcements and withdrawals turn out to be structurally different operations.

Now the message itself:

@dataclass(frozen=True)
class BGPUpdate:
    nlri: tuple[str, ...] = ()
    withdrawn_routes: tuple[str, ...] = ()
    path_attributes: PathAttributes | None = None
Enter fullscreen mode Exit fullscreen mode

Three fields, and they tell you everything the incoming message can contain:

  • withdrawn_routes — prefixes that should disappear from the table.
  • nlri — Network Layer Reachability Information, the prefixes being announced.
  • path_attributes — the context that goes with the announcement. Optional, and its optionality is doing real work, which we'll get to.

The first thing to notice: all three default to empty/None. A valid UPDATE can carry only withdrawals, only announcements, or both at once. That's not an accident of the toy model — it's true of real BGP, and it's the crux of the whole session.

The state that changes

Everything above is input. Here's the thing that actually mutates:

@dataclass
class RoutingTable:
    paths_by_prefix: dict[str, list[PathAttributes]] = field(default_factory=dict)

    def apply_update(self, prefix: str, attributes: PathAttributes) -> None:
        self.paths_by_prefix.setdefault(prefix, []).append(attributes)

    def withdraw(self, prefix: str) -> None:
        self.paths_by_prefix.pop(prefix, None)
Enter fullscreen mode Exit fullscreen mode

paths_by_prefix is the current routing state after each mutation. And now look closely at the two methods, because this is where withdrawal and announcement reveal themselves as different kinds of work:

  • apply_update() adds. It appends a PathAttributes to the list for a prefix, creating the list if it doesn't exist. Announcing is additive and it requires attributes — you cannot announce a prefix without saying how to reach it.
  • withdraw() removes. It pops the prefix entirely, and — this matters — pop(prefix, None) never raises. Withdrawing a prefix that isn't there is a no-op, not an error.

These are not two flavors of the same operation. One needs a full PathAttributes payload; the other needs nothing but a prefix name. That asymmetry is the answer to "why is route withdrawal different from session failure" hiding in plain sight — but let's watch them combine first.

One message, two kinds of work

Here is the whole session in one function:

def apply_update_message(table: RoutingTable, update: BGPUpdate) -> RoutingTable:
    """Apply withdrawals first, then announcements from a single UPDATE."""
    for prefix in update.withdrawn_routes:
        table.withdraw(prefix)

    if update.path_attributes is None:
        return table

    for prefix in update.nlri:
        table.apply_update(prefix, update.path_attributes)

    return table
Enter fullscreen mode Exit fullscreen mode

Read it as a sequence of decisions, not as a parser:

  1. Withdrawals go first. Every prefix in withdrawn_routes is popped before anything is added. Order is deliberate — a single UPDATE that both withdraws and re-announces a prefix ends with the announcement winning, because the removal happens first and the append happens after.

  2. No attributes, no announcement. The guard if update.path_attributes is None: return table is the enforcement of that asymmetry from earlier. An UPDATE that carries only withdrawals has no path_attributes, so the function returns right there — the nlri loop never runs. This is also why an announcement can't sneak through without a path: the attributes are the ticket of entry.

  3. Announcements attach the path. Each prefix in nlri gets update.path_attributes appended. The same attribute set travels with every prefix in this UPDATE, which is exactly why path attributes are a message-level field and not a per-prefix one.

So a single UPDATE can both remove and add routes, in that order, and the shape of the message decides which loops execute.

You can watch this happen. The walkthrough is runnable:

PYTHONPATH=src python3 examples/bgp/session_02_walkthrough.py
Enter fullscreen mode Exit fullscreen mode

It prints a sequence of UPDATEs and shows how paths_by_prefix changes after each one. Run it, then predict the table state before each line prints — that's the whole exercise.

Why a withdrawal is not a dead session

Now we can answer the core question directly, and it's worth being precise because operators mix these up constantly.

withdraw() removes one prefix. That's a routing decision the peer made: "I no longer have a path to 10.0.0.0/24, stop using me for it." The BGP session between you and that peer is perfectly healthy. Other prefixes it advertised are untouched.

A session dying is a different event entirely. When the TCP connection underneath BGP drops — hold timer expires, the peer crashes, the link fails — everything that peer ever told you has to be reconsidered at once. That's not one withdraw() call; it's the invalidation of a whole peer's worth of state.

The toy model draws the line but doesn't yet model the second case, which brings us to the honest part.

Toy Model Boundary

This is a deliberately simplified UPDATE model, and the simplification is specific: it is prefix-wide.

RoutingTable.withdraw() removes the entire prefix entry — paths_by_prefix.pop(prefix, None). In real BGP, that's too blunt. A prefix can be advertised to you by several peers at once, and each peer keeps its own Adj-RIB-In. Withdrawing a prefix should remove one peer's path for it, leaving other peers' paths intact so the best-path selection can fall back to an alternative.

That's why paths_by_prefix maps to a list of PathAttributes — the structure anticipates multiple paths per prefix — but this session's withdraw() doesn't yet key removal by peer. Later sessions refine it into per-peer Adj-RIB-In state, where one peer can disappear while another peer's path for the same prefix survives. Until then, treat "withdraw = drop the whole prefix" as a scaffold, not the real semantics.

Everything else here — withdrawals-before-announcements ordering, attributes as the gate on announcement, one message doing two jobs — is faithful to RFC 4271. The single-path-per-prefix table is the one place the model is knowingly ahead of itself.

The same shape elsewhere

Once you've read this, the pattern is recognizable across the series: a message that carries both "forget this" and "learn this" in a single unit, applied in a fixed order. BGP calls them withdrawn routes and NLRI. But the shape — invalidate first, then install, with state that outlives any single message — is the same one you'll meet again in DNS zone transfers and in any protocol that syncs incremental changes to a table rather than resending it whole. Reading BGP's UPDATE at the code level is really reading incremental state reconciliation, and that's transferable.

Check yourself

Don't take my walkthrough as the answer. Open update.py and see if you can answer these purely by reading the code:

  1. What happens if withdrawn_routes contains a prefix that already exists in the table — and what happens if it contains one that doesn't?
  2. What happens if path_attributes is None but nlri is non-empty? Which line decides the outcome?
  3. If a single UPDATE contains both withdrawals and NLRI for the same prefix, what's in the table when the function returns — and why does the docstring's "withdrawals first" matter here?

If you can trace each of those to a specific line without running anything, you've read the session. If you can't, run the walkthrough and come back to the source.

Further reading

  • RFC 4271 §3.1 — Routes, NLRI, and path attributes
  • RFC 4271 §4.3 — UPDATE message format
  • RFC 4271 §5 — Path attributes

Top comments (0)