Skip to content
academia.sh

Lesson 06 / 34

Legacy Protocols

The design and legacy of envelope-based protocols: separating header from body, carrying the operation name inside the envelope, reporting errors in the envelope instead of the transport layer, how the envelope's cost changes with record count, and which of today's interface decisions were inherited from here.

Contents

The previous lesson built an interface that works over a single endpoint, with a selection document carried in the body. This mechanism is not new. There is a family of protocols that built the same idea — single address, single method, naming the operation in the body — much earlier, and part of today’s interface decisions are inherited directly from there.

This family’s common building block is the envelope: a container that wraps the entire message and holds a header and a body inside it. SOAP is the best-known example of this structure and is built on XML; its Envelope, Header, Body, and Fault elements are exactly what this lesson’s Envelope, Header, Body, and Fault elements are built to match.

The Idea of the Envelope

The problem the envelope solves is this: carrying a message’s own metadata alongside it, independent of the protocol carrying the message. HTTP headers already do this job, but the envelope design is not tied to HTTP — the same envelope can travel over a message queue or a mail message too. Its cost and its gain sit together.

The envelope has three structural decisions:

  • The header carries cross-cutting concerns: the trace id, credentials, the operation name. Intermediate layers can read this part and decide without ever parsing the body.
  • The body carries the payload: data specific to the operation. The payload’s shape changes from operation to operation.
  • The error also goes into the body: failure is reported not by the transport layer’s status code but by a fault element inside the body.

A Small Implementation

The following file contains the functions that build and read the envelope. The XML parser converts nested elements into an object; it does not support attributes or namespaces, because what the lesson needs is the structure itself.

// envelope.mjs — a small XML parser and envelope builders.
// Envelope/Header/Body/Fault elements are this lesson's counterpart to SOAP's Envelope/Header/Body/Fault.
const escape = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const unescape = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");

export const writeXml = (name, content) =>
  typeof content === "object" && content !== null
    ? `<${name}>${Object.entries(content).map(([a, d]) => writeXml(a, d)).join("")}</${name}>`
    : `<${name}>${escape(content)}</${name}>`;

// Parser: turns nested elements into an object. Siblings with the same name are collected into an array.
export const readXml = (text) => {
  const element = /<(\w+)>([\s\S]*?)<\/\1>/g;
  const output = {};
  let e, found = false;
  while ((e = element.exec(text))) {
    found = true;
    const value = /<\w+>/.test(e[2]) ? readXml(e[2]) : unescape(e[2]);
    if (e[1] in output) output[e[1]] = [].concat(output[e[1]], value);
    else output[e[1]] = value;
  }
  return found ? output : unescape(text);
};

export const wrap = (operation, trace, body) =>
  `<Envelope>${writeXml("Header", { Operation: operation, Trace: trace })}` +
  `<Body>${Object.entries(body).map(([a, d]) => writeXml(a, d)).join("")}</Body></Envelope>`;

export const faultEnvelope = (code, reason, detail) =>
  `<Envelope><Header><Operation>Fault</Operation></Header><Body>` +
  writeXml("Fault", { Code: code, Reason: reason, Detail: detail }) + `</Body></Envelope>`;

// The one place the intermediary reads: the header can be taken without parsing the body at all.
export const extractHeader = (envelope) => readXml(/<Header>[\s\S]*?<\/Header>/.exec(envelope)[0]).Header;
// server.mjs — single endpoint, single method: the operation name is in the envelope's header.
import { createServer } from "node:http";
import { readXml, wrap, faultEnvelope, extractHeader } from "./envelope.mjs";

const BOOKS = {
  "978-0262033848": { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", pages: 1312 },
  "978-0201896831": { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", pages: 650 },
};

createServer(async (req, res) => {
  res.sendDate = false;
  let raw = "";
  for await (const p of req) raw += p;
  const header = extractHeader(raw);                  // routing: only the header is read
  const body = readXml(raw).Envelope.Body;
  let output;
  if (header.Operation === "GetBook") {
    const book = BOOKS[body.BookRequest.isbn];
    output = book ? wrap("GetBookResponse", header.Trace, { Book: book })
                  : faultEnvelope("Client", "book not found", `isbn: ${body.BookRequest.isbn}`);
  } else output = faultEnvelope("Client", "unknown operation", header.Operation);
  res.setHeader("Content-Type", "text/xml; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(output));
  // Both faults and successful requests return with the same status code: the error is in the envelope, not the transport layer.
  res.writeHead(200).end(output);
}).listen(8481, "127.0.0.1", () => console.log("envelope endpoint 127.0.0.1:8481/service"));

Measurement

// measure.mjs — measures three properties of the envelope-based protocol
import { wrap, readXml, extractHeader } from "./envelope.mjs";

const call = async (operation, trace, body) => {
  const request = wrap(operation, trace, body);
  const y = await fetch("http://127.0.0.1:8481/service",
    { method: "POST", headers: { "Content-Type": "text/xml" }, body: request });
  const text = await y.text();
  return { status: y.status, sent: Buffer.byteLength(request), received: Buffer.byteLength(text),
           text, parsed: readXml(text).Envelope.Body };
};

console.log("-- 1) single endpoint, operation name in the envelope --");
const good = await call("GetBook", "trace-1", { BookRequest: { isbn: "978-0262033848" } });
console.log(`  request  : ${wrap("GetBook", "trace-1", { BookRequest: { isbn: "978-0262033848" } })}`);
console.log(`  response : ${good.text}`);
console.log(`  HTTP ${good.status}  sent=${good.sent} B  received=${good.received} B`);

console.log("\n-- 2) envelope cost: the same data in JSON and envelope form --");
const RECORD = { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", pages: 1312 };
for (const quantity of [1, 5, 20]) {
  const list = Array.from({ length: quantity }, () => RECORD);
  const json = Buffer.byteLength(JSON.stringify({ Book: list }));
  const envelope = Buffer.byteLength(wrap("GetBookResponse", "trace-1", { Book: list }));
  console.log(`  ${String(quantity).padStart(2)} record  json=${String(json).padStart(4)} B  ` +
    `envelope=${String(envelope).padStart(4)} B  difference=%${Math.round(100 * (envelope / json - 1))}`);
}

console.log("\n-- 3) the error is in the envelope, not the transport layer --");
const bad = await call("GetBook", "trace-2", { BookRequest: { isbn: "978-0000000000" } });
console.log(`  response : ${bad.text}`);
console.log(`  a client that looks at the status code -> HTTP ${bad.status} : "request successful"`);
console.log(`  a client that parses the body           -> ${JSON.stringify(bad.parsed.Fault)}`);

console.log("\n-- 4) an intermediary reads the header without parsing the body --");
for (const msg of [good.text, bad.text]) {
  const bodyLength = /<Body>[\s\S]*<\/Body>/.exec(msg)[0].length;
  console.log(`  header=${JSON.stringify(extractHeader(msg))}  (body ${bodyLength} B, not parsed)`);
}
node server.mjs & p=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null -X POST \
  -d '<Envelope><Header><Operation>x</Operation></Header><Body></Body></Envelope>' http://127.0.0.1:8481/service
node measure.mjs
kill $p
envelope endpoint 127.0.0.1:8481/service
-- 1) single endpoint, operation name in the envelope --
  request  : <Envelope><Header><Operation>GetBook</Operation><Trace>trace-1</Trace></Header><Body><BookRequest><isbn>978-0262033848</isbn></BookRequest></Body></Envelope>
  response : <Envelope><Header><Operation>GetBookResponse</Operation><Trace>trace-1</Trace></Header><Body><Book><isbn>978-0262033848</isbn><title>Introduction to Algorithms</title><author>Cormen</author><pages>1312</pages></Book></Body></Envelope>
  HTTP 200  sent=157 B  received=234 B

-- 2) envelope cost: the same data in JSON and envelope form --
   1 record  json= 104 B  envelope= 241 B  difference=%132
   5 record  json= 480 B  envelope= 709 B  difference=%48
  20 record  json=1890 B  envelope=2484 B  difference=%31

-- 3) the error is in the envelope, not the transport layer --
  response : <Envelope><Header><Operation>Fault</Operation></Header><Body><Fault><Code>Client</Code><Reason>book not found</Reason><Detail>isbn: 978-0000000000</Detail></Fault></Body></Envelope>
  a client that looks at the status code -> HTTP 200 : "request successful"
  a client that parses the body           -> {"Code":"Client","Reason":"book not found","Detail":"isbn: 978-0000000000"}

-- 4) an intermediary reads the header without parsing the body --
  header={"Operation":"GetBookResponse","Trace":"trace-1"}  (body 136 B, not parsed)
  header={"Operation":"Fault"}  (body 115 B, not parsed)

What the Measurement Says

The envelope’s cost is fixed, the payload’s cost is variable. For a single record, the envelope is 132% larger than the same data’s JSON form; at twenty records, this difference drops to 31%. The envelope itself — the header section, the wrapping elements — is a fixed cost independent of record count; the per-record overhead comes from the closing tags and repeats with every record. The envelope is expensive for small, frequent calls; the ratio drops for large payloads.

The cost of reporting the error in the envelope lands on the consumer. When a nonexistent book was requested, the server returned 200. A client that looks at the status code counts the request as successful; seeing the error requires parsing the body. This is the source of the error contract splitting in two, mentioned in the fourth lesson, and it comes back in exactly the same shape in GraphQL’s partial success response.

The header can be read independently of the body. The last part of the measurement shows that a body over a hundred bytes can have its header taken without being parsed at all. This means an intermediate layer — a router, an authorization layer, a tracing layer — can do its job without understanding the payload. The trace id the header carries links the same request’s records across different components together.

The Legacy Left Behind

The aspects of envelope-based design carried forward to today are a direct continuation of the properties seen in the measurement.

A machine-readable contract. This family’s interfaces publish which operations exist and each operation’s input–output types in a separate definition file. Two habits remain from this: the contract being an executable file separate from documentation, and the client side being generated from that file. The same idea shows up as the schema file in the fourth lesson, and later in this course as OpenAPI- and JSON-Schema-based definitions.

A structured error. The triple inside the fault element — code, reason, detail — is the ancestor of today’s standard error bodies: a code the machine will read, an explanation the human will read, and a context-specific extra. This structure is detailed in the Error Response Format lesson.

Separating cross-cutting concerns. Keeping identity, trace, and routing information apart from the payload has been preserved; in HTTP-based interfaces, this separation lives in HTTP headers instead of the envelope’s header.

Carrying the operation name in the body. The pattern of a single endpoint with the operation name in the body persists in both remote-procedure-call-style HTTP interfaces and in GraphQL.

What was not inherited is just as clear. The desire to be transport-independent renders HTTP’s own semantics — methods, status codes, conditional requests, cache directives — unused. The cacheability gain measured in the third lesson cannot be obtained in a design where every request is POST. Together with the text format’s overhead of detail, this is the real reason that opened the way for designs that do the same job with fewer bytes and fewer layers.

Summary

  • Envelope-based protocols work with a container that wraps the message as a header and a body; the operation name is carried not in the address but in the envelope’s header, and the transport layer is treated as replaceable.
  • In the measurement, the envelope’s byte cost came out to 132% for a single record and 31% for twenty records: the envelope itself is a fixed cost, and the closing tags are a cost that repeats per record.
  • Because the error is reported inside the envelope, the server returns 200 even on a failed operation; a client that looks at the status code cannot see the error and is forced to parse the body.
  • Because the header can be read independently of the body, intermediate layers can do routing, authorization, and tracing without ever parsing the payload.
  • The inherited legacy is four items: the machine-readable contract file, the structured error shape, separating cross-cutting concerns from the payload, and a named operation over a single endpoint.

Next Step

The shared assumption of the five styles so far is the same: the client initiates communication, the server responds, the exchange ends. Some of the library service’s jobs do not fit this pattern. A book returning to the shelf is an event that happens on the server, and nobody asks the waiting client about it. A count job runs for minutes, and keeping the request open is not an option. The next lesson builds communication forms outside the request–response pattern and compares two of them by measurement: how many connections and how much delay does an event stream versus long polling take to deliver the same notification?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close