A BGP speaker is just state plus three event handlers
This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. The source lives at pathvector-studio/protocol-in-code, and every article quotes from a Python file you can open and run yourself. If you're earlier in your networking journey and want to type commands and watch packets before you read code, start with the hands-on companion series Protocol Lab instead.
The question
What does the smallest readable BGP speaker look like when all the previous sessions are connected?
That's the question this module turns over. Not "how do I configure a BGP daemon," and not "what does a production speaker do" — but something narrower and, I think, more useful: if you've already written the session gate, the export refresh, the per-event branches, and the policy-aware best-path selection as separate functions, what's actually left to write?
The answer is smaller than most people expect. The file we're reading, src/protocol_in_code/bgp/speaker.py, is about 120 lines, and roughly half of them are argument lists.
Everything that came before
The speaker isn't inventing anything. It's assembling:
- Session 11 gave us the session gate — the check that decides whether a peer is even in a state where its announcements count.
- Session 12 gave us export refresh — recomputing what we advertise outward when the local RIB changes.
- Session 13 gave us event-specific branches —
process_announce_event,process_withdraw_event,process_peer_down_event, each with its own shape. - Session 14 gave us prefix-wide, policy-aware best-path selection.
This session wraps those event branches as methods on a single object. That's the whole move.
Note: This is the same pattern you'll see in the TCP track when the congestion-control functions get wrapped into a connection object, and in the DNS track when the cache and the resolver loop get joined. A protocol implementation is almost always pure decision functions plus a mutable bag of state that calls them in order. Once you see that shape, the "which daemon should I read next" question gets a lot less intimidating.
Read the speaker as a bag of state
Start at the dataclass. Don't read it as a class definition — read it as an inventory of everything a BGP speaker has to remember:
@dataclass
class ToyBGPSpeaker:
vrps: list[VRP]
policies: PipelinePolicies
adj_rib_in: AdjRIBIn = field(default_factory=AdjRIBIn)
loc_rib: LocRIB = field(default_factory=LocRIB)
adj_rib_out: AdjRIBOut = field(default_factory=AdjRIBOut)
peers: dict[str, PeerSession] = field(default_factory=dict)
export_targets: dict[str, ExportTarget] = field(default_factory=dict)
Seven fields, and every one of them maps to something you'd point at on a whiteboard:
adj_rib_in— what peers told us, before we judged it.loc_rib— what we decided to believe.adj_rib_out— what we're telling other peers.peers— session state per neighbor (this is where the Session 11 gate reads from).export_targets— who we advertise to, and under what outbound policy.vrpsandpolicies— the validation and policy inputs that the pipeline consults.
The three-RIB split is the part worth sitting with. It's not an implementation detail; it's the reason BGP can be policy-driven at all. If there were one table, "what I heard," "what I chose," and "what I say" would be the same thing, and there'd be nowhere to put a policy decision. The Adj-RIB-In → Loc-RIB → Adj-RIB-Out chain exists precisely so that each arrow can have a filter on it.
Note also what has no default: vrps and policies are required constructor arguments. You cannot build this speaker without deciding what you validate against and what your policies are. That's a deliberate bit of API design — in the toy model, an unpolicied speaker isn't a thing you can accidentally create.
The three-line rhythm
Now read one of the event handlers. Here's receive_announce() in full:
def receive_announce(
self,
peer_id: str,
prefix: str,
attributes: PathAttributes,
) -> SpeakerStep:
result = process_announce_event(
AnnounceEvent(peer_id=peer_id, prefix=prefix, attributes=attributes),
self.peers,
self.adj_rib_in,
self.loc_rib,
self.adj_rib_out,
self._all_export_targets(),
self.vrps,
self.policies,
)
return self._step_from_event("announce", result)
Strip the argument list and it's two statements:
result = process_announce_event(...)
return self._step_from_event("announce", result)
Build an event object. Hand it to the pure function from Session 13 along with all the state. Turn the result into a report. That rhythm — event in, decision function, step out — is the entire course in miniature, and it repeats verbatim in the other two handlers.
receive_withdraw() is the same shape with one fewer argument:
def receive_withdraw(self, peer_id: str, prefix: str) -> SpeakerStep:
result = process_withdraw_event(
WithdrawEvent(peer_id=peer_id, prefix=prefix),
self.adj_rib_in,
self.loc_rib,
self.adj_rib_out,
self._all_export_targets(),
self.vrps,
self.policies,
)
return self._step_from_event("withdraw", result)
Spot the difference before you read on: self.peers isn't passed. Announce and peer-down both take it; withdraw doesn't. That asymmetry is not an oversight, and it's the single best thing to reason about in this file. A withdraw doesn't need the session gate, because a withdraw is a removal — there's nothing to validate, no attributes to run through policy, no decision about whether the peer is allowed to say this. Announce needs the gate because accepting a path is a judgment call. Peer-down needs the peers dict because it mutates it — the session state gets downgraded as part of the event.
Read the argument lists as a statement about what each event is allowed to touch. That's a habit worth carrying into real codebases.
The result type
Every handler returns the same thing:
@dataclass(frozen=True)
class SpeakerStep:
event: str
accepted: bool
prefixes: tuple[str, ...]
installed_paths: dict[str, PathCandidate | None]
export_changes: tuple[ExportChange, ...]
And the conversion is mechanical:
def _step_from_event(self, event: str, result: EventResult) -> SpeakerStep:
return SpeakerStep(
event=event,
accepted=result.accepted,
prefixes=result.touched_prefixes,
installed_paths=result.best_paths,
export_changes=result.export_changes,
)
This looks like pointless indirection until you ask what it's for. EventResult is the internal vocabulary of the event layer; SpeakerStep is the speaker's public answer to "what just happened?" Keeping them separate means the event functions can grow new internal fields without changing what callers see, and it gives you one uniform shape to log or assert against regardless of which event fired.
The field types carry information too. installed_paths is dict[str, PathCandidate | None] — the None is load-bearing. A prefix mapping to None means "this prefix was touched and now has no best path," which is a genuinely different outcome from the prefix not appearing in the dict at all. accepted being a single bool, meanwhile, tells you these events are all-or-nothing at the speaker level: an announce either got through the gate and the pipeline or it didn't.
export_changes is where the Session 12 work surfaces. A single announce can ripple into multiple outbound advertisements — one per export target whose view of the prefix changed — which is why it's a tuple rather than a single value.
Wiring and running it
Two small setup methods complete the object:
def add_neighbor(self, peer_id: str, config: BGPSessionConfig) -> None:
self.peers[peer_id] = open_peer_session(peer_id, config)
def add_export_target(self, target: ExportTarget) -> None:
self.export_targets[target.peer_id] = target
def _all_export_targets(self) -> tuple[ExportTarget, ...]:
return tuple(self.export_targets.values())
Both are dict inserts keyed by peer_id, and _all_export_targets() freezes the values into a tuple on every call — the event functions receive a snapshot they can't mutate, which keeps the "pure function" contract honest even though the speaker itself is mutable.
The walkthrough is runnable:
PYTHONPATH=src python3 examples/bgp/session_15_walkthrough.py
Watch for five things as it steps through:
- The first announce installing one path —
installed_pathsgoes from nothing to aPathCandidate. - The second announce either keeping or changing the best path — this is Session 14's comparison logic firing.
- A withdraw moving the prefix to the remaining peer — the
Loc-RIBre-selects from what's left inAdj-RIB-In. - Peer-down removing the last remaining path and downgrading the peer's session state — one event mutating two kinds of state.
- Outbound advertisements changing as each of the above happens —
export_changesshould be non-empty exactly when theLoc-RIBdecision actually changed.
That last one is the interesting audit. If you see a Loc-RIB change with no corresponding export change, either a policy filtered it or you've found a bug. Both are worth chasing.
Toy model boundary
This is a teaching model, and being precise about what it isn't matters more here than in earlier sessions — because "a BGP speaker" sounds like a complete thing, and this isn't one.
What's absent:
- No timers. No hold timer, no keepalive, no MRAI (Minimum Route Advertisement Interval). Real speakers damp their advertisement rate; this one advertises the instant a decision changes. A significant fraction of real BGP behavior — including a good chunk of convergence dynamics — lives in those timers.
- No packet parsing.
receive_announce()takes aPathAttributesobject, not bytes. There is no wire format here, no TLV walking, no length validation, none of the malformed-attribute error handling that RFC 4271 spends real pages on. - No capability negotiation. No OPEN message, no capability exchange, no AFI/SAFI negotiation.
add_neighbor()just constructs a session and puts it in a dict. - No UPDATE encoding.
export_changesdescribes decisions, not messages. Nothing here would go on a socket. - No FSM. Real BGP has a six-state finite state machine (Idle, Connect, Active, OpenSent, OpenConfirm, Established). The toy model has a session gate and a
peer_down()that downgrades state. - No transport. No TCP connection, no port 179, no MD5 or TCP-AO.
What this model does show is the control-plane loop shape: an event arrives, state is consulted, a decision function runs, RIBs move, and outbound state is recomputed. That shape is real, and it's the part that stays the same across implementations. Everything in the list above is a layer wrapped around it.
Same shape, different protocol
Once you can see ToyBGPSpeaker as mutable state + pure event functions + a uniform result type, you'll find the pattern everywhere. A TCP connection object is a bag of state (cwnd, ssthresh, sequence numbers, retransmission queue) with event handlers for ACK-received, timeout-fired, and data-queued — each one consulting state, running a decision function, and returning what changed. A DNS resolver is a cache plus handlers for query-received and response-received.
The Adj-RIB-In / Loc-RIB / Adj-RIB-Out split has its own echoes: it's the same "raw input, decided truth, published view" separation you see in a TLS session cache holding tickets separately from the live parameters actually negotiated, or in conntrack keeping the observed tuple separate from the translated one it emits.
Different protocol, different field names, same three moving parts.
Check yourself
Before you close the file, see whether you can answer these from the source alone — no running required:
receive_withdraw()doesn't receiveself.peers, butreceive_announce()andpeer_down()do. What breaks — and what correctly doesn't break — if a withdraw arrives from a peer whose session has gone down?_all_export_targets()builds a fresh tuple on every event. If an export target were added mid-event by some other code path, would the in-flight event see it? Does the answer differ forpeers, which is passed as the live dict?installed_pathsisdict[str, PathCandidate | None]. Trace which event can produce aNonevalue, and which can produce a prefix inprefixesthat has no entry ininstalled_pathsat all. Are those the same situation?
Don't take my word for any of it — the file is short enough to hold in your head, which is the entire point of a toy model.
Done when
You can explain the speaker as one object holding state plus three event handlers, and you can see how Sessions 01–14 collapse into a single readable control-plane loop. If someone asks you "what does a BGP speaker do?", the answer you reach for should now be structural rather than a list of features.
Further reading
- RFC 4271 — A Border Gateway Protocol 4 (BGP-4) — especially §9 (UPDATE Message Handling) for the decision process this model compresses, and §8 for the FSM the toy model omits.
- RFC 4632 — CIDR — the addressing model underneath every prefix in the RIBs.
- RFC 6811 — BGP Prefix Origin Validation — where the
VRPtype comes from. - Source for this session:
src/protocol_in_code/bgp/speaker.py