One socket, many requests
Many in-flight requests over one persistent socket, matched back by correlation ID.
Some protocols keep one persistent connection and send many requests through it at the same time. Each request includes a correlation ID, the remote side answers in whatever order suits it, and a reader loop matches every reply back to the call that waits for it. Payment switches speaking ISO 8583 and trading systems speaking FIX are the classic examples.
With the Connector SDK, the whole mechanism lives inside the client class you write - the connector declares it like any other and services call plain methods, unaware that their requests share a socket.
The connector module
The example wraps a payment switch. The client owns one socket, a map of in-flight calls and a reader loop that wakes each call up when its reply arrives:
# -*- coding: utf-8 -*-
# stdlib
import itertools
import socket
import threading
# Zato
from zato.common.sdk import Connector, ConnectionLost, Field
class PaymentsClient:
""" A client for a payment switch that multiplexes many in-flight requests over one persistent
socket - each request has a correlation ID, replies can arrive in any order and a reader
loop matches them back to the calls that wait for them (ISO 8583, FIX style).
"""
def __init__(self, host:'str', port:'int') -> 'None':
# The one socket all the requests share.
self.socket = socket.create_connection((host, port))
self.reader_file = self.socket.makefile('r', encoding='utf8')
# Guards the pending map and writes to the shared socket.
self.lock = threading.Lock()
# Calls in flight, keyed by their correlation IDs.
self.pending = {}
# Where the correlation IDs come from.
self.counter = itertools.count(1)
# Set to False by the reader loop once the socket is gone.
self.is_connected = True
# The reader loop matches replies back to waiting calls for as long as the connection lives.
reader_thread = threading.Thread(target=self._read_loop, daemon=True)
reader_thread.start()
def _read_loop(self) -> 'None':
for line in self.reader_file:
text = line.strip()
corr_id, _, payload = text.partition(' ')
# The call this reply belongs to may have given up already, e.g. it timed out.
with self.lock:
holder = self.pending.pop(corr_id, None)
if holder:
holder['response'] = payload
holder['event'].set()
# The loop ended, which means the socket is gone - wake up everyone still waiting.
self.is_connected = False
with self.lock:
for holder in self.pending.values():
holder['event'].set()
self.pending.clear()
def request(self, payload:'str') -> 'str':
# A dead socket cannot deliver anything - the framework will reconnect.
if not self.is_connected:
raise ConnectionLost('The payment switch connection is down')
corr_id = str(next(self.counter))
holder = {'event': threading.Event(), 'response': None}
# Register the call and send its request under one lock, so the reply cannot
# arrive before the call is registered.
with self.lock:
self.pending[corr_id] = holder
self.socket.sendall(f'{corr_id} {payload}\n'.encode('utf8'))
# Wait for the reader loop to match the reply back to this call.
_ = holder['event'].wait()
# A wake-up without a response means the connection died while this call was in flight.
if holder['response'] is None:
raise ConnectionLost('The payment switch connection went down mid-call')
return holder['response']
def close(self) -> 'None':
self.reader_file.close()
self.socket.close()
class PaymentsConnector(Connector):
""" Wraps the payment switch as a connection type that services access through self.out.payments.
"""
type = 'payments'
# Configuration schema
host = Field.Text()
port = Field.Int(default=9970)
def create_client(self) -> 'PaymentsClient':
return PaymentsClient(self.config.host, self.config.port)
def ping(self, client:'PaymentsClient') -> 'None':
client.request('ping')
def on_stop(self, client:'PaymentsClient') -> 'None':
client.close()
def authorize(self, payload:'str') -> 'str':
return self.client.request(payload)
In the code above:
- One client serves all the services on a server - the requests they make concurrently are all in flight together, over the one socket
ConnectionLostis the signal that makes the platform evict the client and reconnect - raise it whenever the client discovers the socket is gone, and the next invocation will find a freshly built client in place- The reader loop wakes up every waiting call when the socket dies, so no caller is ever left hanging on a connection that no longer exists
Creating a definition
Definitions are managed with enmasse under a key derived from the connector's type - custom_ plus the type name:
Using it from services
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class AuthorizePayment(Service):
""" Authorizes one payment over the multiplexed connection to the payment switch.
"""
name = 'demo.payments.authorize'
input = 'payload'
def handle(self) -> 'None':
conn = self.out.payments['My Payments']
response = conn.authorize(self.request.input.payload)
self.response.payload = response
Concurrent invocations of this service share the one socket and each still gets its own reply, even when the switch answers out of order.