Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-01/ — 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. The full course and every source file it reads live in the repo: github.com/pathvector-studio/protocol-in-code. If you're newer to this and want to build the muscle by doing rather than reading, start with the hands-on companion series, Protocol Lab, then come back here.
Here's the question to keep turning over while you read:
What inputs are required to form a BGP neighbor, and where does the session stop when one of them is missing?
That second half is the interesting part. Most people can recite that BGP needs a peer IP and an AS number. Far fewer can tell you which state a session lands in when TCP is fine but the OPEN negotiation fails — versus when TCP itself never came up. Those are different failures, and they stop the machine in different places. The only way to know the difference cold is to read the branches.
The inputs: what even goes into a session
Before you can reason about failure, you need to know the full surface of inputs. In the toy model that's a single dataclass, and reading it is the fastest way to see what a BGP session actually depends on:
@dataclass(frozen=True)
class BGPSessionConfig:
peer_ip: str
peer_as: int
local_as: int
tcp_reachable: bool
hold_time: int = 180
keepalive_time: int = 60
capabilities: tuple[BGPCapability, ...] = field(default_factory=tuple)
open_message_ok: bool = True
keepalive_received: bool = True
Read that as a checklist of everything a neighbor needs, and why:
-
peer_ip— without a destination, TCP can't even start. No IP, no dial tone. -
peer_asandlocal_as— you need a remote identity to validate against and a local identity to announce in your OPEN message. A BGP speaker introduces itself by AS number. -
tcp_reachable— this is the one people skip past. BGP is an application riding on top of TCP. Before a single BGP message is exchanged, a TCP connection has to succeed. -
hold_time/keepalive_time— a session isn't just created, it's maintained under timer rules. -
capabilities— the two peers have to agree on what they're even able to talk about. -
open_message_ok— OPEN negotiation can fail on its own terms, even when TCP is perfectly healthy. -
keepalive_received— the session isn't fully established until a KEEPALIVE has actually come back.
Notice that peer_ip alone is nowhere near enough. It gets you a destination and nothing else. Everything after it is another gate the session has to pass through.
The states it can stop at
The second thing to read is the enum. It's the vocabulary the code uses for "how far did we get":
class SessionState(str, Enum):
IDLE = "Idle"
CONNECT = "Connect"
ACTIVE = "Active"
OPEN_SENT = "OpenSent"
OPEN_CONFIRM = "OpenConfirm"
ESTABLISHED = "Established"
The canonical happy path runs straight through these:
Idle -> Connect -> Active -> OpenSent -> OpenConfirm -> Established
But the enum isn't just a list of milestones you pass through on success. Each of these is also a place a failed session can come to rest. That's the mental shift this session is asking for. Established is not a config line you set — it's the label you earn only after every check upstream has passed.
Reading the machine
Now the main event. The whole state machine is one function, and it's worth reading top to bottom as a sequence of early returns — because each early return is a different way the session can stall:
def establish_neighbor(config: BGPSessionConfig) -> SessionState:
"""Walk a minimal BGP session state machine."""
state = SessionState.IDLE
if not config.peer_ip:
return state
state = SessionState.CONNECT
if not config.tcp_reachable:
return SessionState.ACTIVE
state = SessionState.OPEN_SENT
if config.peer_as <= 0 or config.local_as <= 0:
return state
if not config.open_message_ok:
return state
state = SessionState.OPEN_CONFIRM
if not config.keepalive_received:
return state
return SessionState.ESTABLISHED
Walk the gates in order:
Leaving Idle. We start at IDLE. The very first check is if not config.peer_ip. No peer IP means we never even try to dial, so we return Idle unchanged. This is the "you haven't given me anywhere to go" failure.
Connect, and the fallback to Active. Once we have an IP we move to CONNECT — meaning "attempting the TCP connection." Then:
state = SessionState.CONNECT
if not config.tcp_reachable:
return SessionState.ACTIVE
If TCP isn't reachable, we don't sit in Connect — we fall back to Active. This is the detail that trips people up. In real BGP, Active doesn't mean "up and doing things"; it means the speaker tried to connect, failed, and is now waiting to retry. It's arguably the most badly named state in all of networking. The code makes the semantics unambiguous: reaching Active here means TCP did not come up.
OpenSent, and the two ways to be stuck there. If TCP succeeded, we advance to OPEN_SENT — the OPEN message is on the wire and we're waiting on the peer. Two separate checks can pin us here:
state = SessionState.OPEN_SENT
if config.peer_as <= 0 or config.local_as <= 0:
return state
if not config.open_message_ok:
return state
Invalid AS numbers (either side ≤ 0) stop us in OpenSent. So does a failed OPEN negotiation. Both return the same state, but they're distinct causes — one is "your identities don't validate," the other is "the OPEN exchange itself was rejected." The key takeaway: an OPEN failure and a TCP failure stop the session in different places — OpenSent versus Active. If someone hands you a stuck session and says "it's in OpenSent," you already know TCP is fine and the problem is up in the BGP layer.
OpenConfirm, waiting on KEEPALIVE. Pass the OPEN checks and we move to OPEN_CONFIRM, which means "OPEN was accepted, now I need to hear a KEEPALIVE back":
state = SessionState.OPEN_CONFIRM
if not config.keepalive_received:
return state
No KEEPALIVE, and we rest at OpenConfirm. Only when that final gate passes do we return Established.
That last line is the whole point of the session. Established isn't a setting — it's the result of clearing every check above it, in order.
Run it and watch the states
The reading is the work, but the walkthrough lets you confirm your mental model against real output. It runs several BGPSessionConfig scenarios and prints which state each one reaches:
PYTHONPATH=src python3 examples/bgp/session_01_walkthrough.py
Before you run it, try to predict each scenario's landing state from the source alone. If your prediction and the printout disagree, that gap is exactly the thing to go re-read.
Toy Model Boundary
This is a deliberately minimal model, and being honest about what it isn't is the point.
The dataclass exposes hold_time, keepalive_time, and capabilities because they're part of a realistic BGP session's surface — you should know they exist. But establish_neighbor() does not actually negotiate or enforce them yet. In this session, the branches that really fire depend only on peer_ip, tcp_reachable, valid AS numbers, open_message_ok, and keepalive_received. The timers and capabilities are along for the ride as structure, not behavior.
A few more things the real protocol does that this model collapses:
-
Timing is boolean here.
tcp_reachable,open_message_ok, andkeepalive_receivedare pre-decided flags. Real BGP discovers each of these over time, with retries, backoff, and theConnectRetry,Hold, andKeepalivetimers governing the pace. Here,Activeis a terminal return value; in the real FSM it's a state you loop back out of on the next connection attempt. - No collision detection, no passive/active roles. Real speakers can both initiate, detect the collision, and tear one connection down. This model has a single directional walk.
-
OPEN is a boolean, not a negotiation.
open_message_okhides the actual content — version, AS, hold time, BGP Identifier, and the capability optional parameters thatcapabilitiesis standing in for. Capability mismatch handling doesn't exist here.
None of that makes the model wrong — it makes it a lens. The FSM's shape (each input is a gate; each failure has a resting state) is exactly right, and that shape is what carries over to the real thing.
The same shape, elsewhere
The move this session trains — stop asking "did the config look correct?" and start asking "which field was missing, which condition failed, which state was the last successful one?" — is not specific to BGP. It's the reading lens for every stateful protocol handshake. A TLS handshake stalling at a specific message, a TCP connection stuck in SYN_SENT versus SYN_RECEIVED: same idea. The state you're stuck in is the diagnosis, because it tells you which gate you cleared and which one you didn't. Learn to read one FSM this way and you can read all of them.
Self-check: can you answer these from the source alone?
Don't run anything for these — answer them by reading establish_neighbor(), then verify. The point is to prove the state machine lives in your head, not your notes:
- If
peer_ipis empty, what state comes back — and why is it that one rather thanConnect? - If TCP is unreachable, why does the session land in
Activeinstead of simply staying inConnect? - Invalid AS numbers and a failed OPEN both return the same state — which one, and how would you tell those two failures apart if all you had was the returned state?
If you can answer all three cold, you've got the session: BGP doesn't start at OPEN — it starts after TCP reachability, OPEN failure and TCP failure stop in different places, and Established is the result of passing several checks, not a single setting.
Further reading
- RFC 4271 §1.1 — BGP overview and the role of TCP
- RFC 4271 §3 — session summary and the peering model
- RFC 4271 §4.2 — the OPEN message format and its fields
The source file for this session is src/protocol_in_code/bgp/session.py, and the runnable walkthrough is examples/bgp/session_01_walkthrough.py. Read the code, then make it lie to you — hand it a broken config and see if you called the resting state before Python did.
Top comments (0)