Skip to content
academia.sh

Lesson 22 / 34

Deprecation Policy

Announcing deprecation in the response itself, collecting per-consumer version telemetry, and calculating the consumer count and traffic share remaining on the old version at the end of the transition period.

Contents

The differ detected seven breaking changes and raised the version to 2.0.0. Raising the version number does not migrate old clients on its own; v1 still works, and every client that calls it keeps working. Maintaining v1 forever is not an option for the library either: two versions means every bug fix has to be done twice and every new field has to be produced in two shapes.

The path between the two is deprecation: the version is first declared deprecated, keeps running for a while longer, and is then closed. This lesson establishes three things — where the announcement is written, how to measure who is still on the old version, and which number the sunset date is chosen by looking at.

Writing the Announcement Into the Response

If the announcement is written into the documentation, only those who read the documentation see it. The party the announcement needs to reach is the code that calls the old version; the person who wrote that code cannot be expected to read the documentation that exact week. That is why the announcement is put into the response itself.

Two headers define this. Deprecation reports the moment the resource was deprecated; Sunset reports the moment access will end. The two are separate information: the first says “do not use this anymore,” the second says “this will stop working on this date.” The Link headers placed alongside them point to the replacement resource and the migration guide; an announcement that only says “this is going away” speeds up no migration.

// server.mjs — serves the v1 resource with a deprecation announcement
// Usage: node server.mjs <today>   example: node server.mjs 2026-04-10
import { createServer } from "node:http";

const TODAY = new Date(process.argv[2] ?? "2026-04-10");
const ANNOUNCED = new Date("2026-03-01T00:00:00Z");   // the date deprecation was announced
const SUNSET = new Date("2026-09-01T00:00:00Z");      // the date access will end

const RECORD = { id: "O-1", member: "U-1001", isbn: "978-0262033848", returnDate: "2026-03-20" };

// Telemetry: which consumer called which version how many times.
const counts = new Map();   // "consumer|version" -> request count

createServer((req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  if (path === "/telemetry") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify([...counts].map(([a, s]) => ({ consumer: a.split("|")[0], version: a.split("|")[1], requests: s }))));
  }

  const version = /^\/v(\d+)\//.exec(path)?.[1];
  const consumer = req.headers["api-consumer"] ?? "unknown";
  if (version) counts.set(`${consumer}|v${version}`, (counts.get(`${consumer}|v${version}`) ?? 0) + 1);

  if (path === "/v2/loans/O-1") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify({ id: RECORD.id, member: { id: RECORD.member }, items: [{ isbn: RECORD.isbn }], dueDate: RECORD.returnDate }));
  }

  if (path === "/v1/loans/O-1") {
    // If the sunset date has passed, the resource no longer exists: 410, a permanent end.
    if (TODAY >= SUNSET) {
      res.writeHead(410, { "content-type": "application/problem+json; charset=utf-8", link: '</v2/loans/O-1>; rel="successor-version"' });
      return res.end(JSON.stringify({
        type: "https://example.library/problems/version-sunset", title: "Version sunset", status: 410,
        detail: `v1 was sunset on ${SUNSET.toUTCString()}; use /v2/loans.`, instance: "oc-0001",
      }));
    }
    // Announcement: Deprecation gives the moment of deprecation, Sunset the moment access ends.
    res.writeHead(200, {
      "content-type": "application/json; charset=utf-8",
      deprecation: `@${Math.floor(ANNOUNCED.getTime() / 1000)}`,
      sunset: SUNSET.toUTCString(),
      link: '</v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"',
    });
    return res.end(JSON.stringify(RECORD));
  }

  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found", status: 404, detail: `${path} does not exist.`, instance: "oc-0002" }));
}).listen(8436, "127.0.0.1", () => console.log(`server 127.0.0.1:8436 today=${TODAY.toISOString().slice(0, 10)}`));
#!/usr/bin/env bash
# Same request before and after the sunset date; then a telemetry dump.
for today in 2026-04-10 2026-09-02; do
  node server.mjs "$today" & s=$!
  sleep 0.5
  echo "--- $today ---"
  curl -sS -D - -o /tmp/g -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v1/loans/O-1 \
    | grep -iE '^HTTP|^deprecation|^sunset|^link' | tr -d '\r'
  cat /tmp/g; echo
  if [ "$today" = "2026-04-10" ]; then
    curl -sS -o /dev/null -H 'Api-Consumer: shelf-terminal' http://127.0.0.1:8436/v1/loans/O-1
    curl -sS -o /dev/null -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v2/loans/O-1
    echo "telemetry: $(curl -sS http://127.0.0.1:8436/telemetry)"
  fi
  kill "$s"; wait "$s" 2>/dev/null
done
server 127.0.0.1:8436 today=2026-04-10
--- 2026-04-10 ---
HTTP/1.1 200 OK
deprecation: @1772323200
sunset: Tue, 01 Sep 2026 00:00:00 GMT
link: </v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}
telemetry: [{"consumer":"mobile-app","version":"v1","requests":1},{"consumer":"shelf-terminal","version":"v1","requests":1},{"consumer":"mobile-app","version":"v2","requests":1}]
server 127.0.0.1:8436 today=2026-09-02
--- 2026-09-02 ---
HTTP/1.1 410 Gone
link: </v2/loans/O-1>; rel="successor-version"
{"type":"https://example.library/problems/version-sunset","title":"Version sunset","status":410,"detail":"v1 was sunset on Tue, 01 Sep 2026 00:00:00 GMT; use /v2/loans.","instance":"oc-0001"}

Three details matter. The announcement headers arrive together with a successful response; the request has been fulfilled, only a warning accompanies it. The code returned after the sunset is not 404 but 410: 404 says “there is nothing here,” 410 says “there was something here and it was permanently removed”; the second prevents the client from thinking it built the wrong address. And the Link header stays in the 410 response too, because the moment of sunset is exactly when migration information is needed most.

The telemetry line is the third part. The server counts every request by consumer identity and version; without this counter, the sunset date is chosen by guesswork. The consumer identity arrives here through a header; once authentication is in place, this information is read from the token and does not depend on the client reporting it.

What Remains at the End of the Transition Period

Telemetry gives how many consumers migrate at what speed. This speed varies by segment: actively developed large consumers migrate fast, small consumers untouched for years migrate slowly. The program below calculates what remains at the end of the transition period for three announcement styles.

// transition.mjs — computes the consumer count and traffic share remaining on the old version at the end of the transition period
// Model: each week, a fixed fraction of a segment's remaining consumers migrates.

const SEGMENTS = [
  { name: "large",  count: 4,   traffic: 0.60 },
  { name: "medium", count: 20,  traffic: 0.30 },
  { name: "small",  count: 176, traffic: 0.10 },
];

// Weekly migration rate: varies by how the announcement was made.
const ANNOUNCEMENTS = {
  "docs only":                 { large: 0.05, medium: 0.03, small: 0.01 },
  "response header":           { large: 0.15, medium: 0.10, small: 0.04 },
  "header + direct warning":   { large: 0.35, medium: 0.10, small: 0.04 },
};

const WEEKS = 26;   // between March 1 and September 1

const remaining = (rate, week) => (1 - rate) ** week;

console.log(`transition period: ${WEEKS} weeks   total consumers: ${SEGMENTS.reduce((t, k) => t + k.count, 0)}\n`);
console.log("announcement style          remaining consumers  remaining traffic  large  medium  small");
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  const fractions = SEGMENTS.map((k) => remaining(rates[k.name], WEEKS));
  const consumers = SEGMENTS.reduce((t, k, i) => t + k.count * fractions[i], 0);
  const traffic = SEGMENTS.reduce((t, k, i) => t + k.traffic * fractions[i], 0);
  console.log(
    `${name.padEnd(28)} ${Math.round(consumers).toString().padStart(19)}  ${(traffic * 100).toFixed(1).padStart(17)}%  ` +
    fractions.map((o, i) => Math.round(SEGMENTS[i].count * o).toString().padStart(5)).join(" ")
  );
}

// If the sunset criterion is traffic share: how much does the window need to stretch?
const THRESHOLD = 0.005;
console.log(`\nweeks needed until remaining traffic drops below ${THRESHOLD * 100}%:`);
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  let h = 0;
  while (SEGMENTS.reduce((t, k) => t + k.traffic * remaining(rates[k.name], h), 0) > THRESHOLD && h < 500) h++;
  console.log(`  ${name.padEnd(28)} ${String(h).padStart(3)} weeks (${(h / 4.35).toFixed(1)} months)`);
}

// Per segment: which segment the remaining traffic comes from
console.log("\ndistribution of remaining traffic across segments under 'response header' announcement:");
const o = ANNOUNCEMENTS["response header"];
const remainingTraffic = SEGMENTS.map((k) => k.traffic * remaining(o[k.name], WEEKS));
const total = remainingTraffic.reduce((a, b) => a + b, 0);
SEGMENTS.forEach((k, i) =>
  console.log(`  ${k.name.padEnd(7)} ${(remainingTraffic[i] * 100).toFixed(2)}%  (${((remainingTraffic[i] / total) * 100).toFixed(0)}% of remaining traffic)`));
transition period: 26 weeks   total consumers: 200

announcement style          remaining consumers  remaining traffic  large  medium  small
docs only                                    146               37.1%      1     9   136
response header                               62                6.3%      0     1    61
header + direct warning                       62                5.4%      0     1    61

weeks needed until remaining traffic drops below 0.5%:
  docs only                    299 weeks (68.7 months)
  response header               75 weeks (17.2 months)
  header + direct warning       74 weeks (17.0 months)

distribution of remaining traffic across segments under 'response header' announcement:
  large   0.88%  (14% of remaining traffic)
  medium  1.94%  (31% of remaining traffic)
  small   3.46%  (55% of remaining traffic)

The table gives three results.

First, the effect of writing the announcement into the response is large: when announced only in the documentation, thirty-seven percent of traffic is still on the old version at the end of six months, while when announced through the response header, this share drops to 6.3 percent. The difference is not a policy change but a change in where the announcement reaches.

Second, reaching out to large consumers directly brings total traffic down from 6.3 percent to only 5.4 percent. Direct warning noticeably speeds up large consumers’ migration, but their share has already evaporated within six months; fifty-five percent of remaining traffic comes from small consumers, and the cost of reaching them one by one is high. The way to reduce remaining traffic does not run through talking to the largest consumers.

Third and most important, the two numbers separate from each other. Under the response-header announcement, 62 consumers are still on the old version at the end of the transition period, but they produce only 6.3 percent of traffic. The answer to “how many consumers are left” postpones the sunset; the answer to “how much traffic is left” makes the sunset possible. The two are two measures of the same reality, and the decision changes depending on which one is looked at.

Choosing the Sunset Date

The last table shows the consequence of setting the criterion as a threshold: bringing remaining traffic below 0.5 percent takes 75 weeks — seventeen months — under the response-header announcement. A six-month window cannot hit this threshold.

This gives three options, and all three are legitimate. The window is extended to seventeen months, and the two versions live together that long. The threshold is relaxed; 6.3 percent is deemed acceptable, and the remaining consumers get a 410 at sunset. Or the sunset is staged: access is cut off at specific hours first, then closed entirely — so consumers who have not migrated notice the sunset in production, but are not caught unprepared at the moment of full closure.

What determines the choice is who the remaining consumers are. If the remaining 6.3 percent is the library’s own shelf terminals, the window is kept short, because the party that will carry out the migration is also the library. If it is outside district libraries’ software, the window is extended. This is exactly why telemetry is collected per consumer: the total share is not enough to make this decision.

It is also useful for the window to be measured by release, not by calendar: the phrase “for one major version” keeps its meaning even if release speed changes. But if the sunset moment is to be reported in the response, it has to be converted into a date; the Sunset header carries a date, not a release number. The two are used together: the policy is written in releases, the announcement is converted to a date.

Summary

  • The place the announcement needs to reach is not the documentation but the code that calls the old version; that is why the Deprecation and Sunset headers are sent together with the successful response.
  • Deprecation reports the moment of deprecation, Sunset the moment access will end; the Link headers alongside them point to the replacement resource and the migration guide.
  • After the sunset, 410 is returned instead of 404, and the migration link stays in the 410 response too.
  • Writing the announcement into the response brings traffic remaining on the old version at the end of six months down from 37 percent to 6.3 percent.
  • More than half of remaining traffic comes from small consumers; reaching out to the largest consumers directly reduces total remaining traffic by only 0.9 percentage points.
  • The remaining consumer count and the remaining traffic share are separate measures, and the sunset decision changes depending on which one is looked at; telemetry is collected per consumer precisely to be able to make this distinction.

Next Step

Everything built up to this point — error format, field-level validation, versions, breakingness rules, the deprecation window — is part of the contract, and all of it has been described in prose so far. Prose has two problems as a contract: it cannot be handed to a machine, and it cannot be compared against reality. The differ was working on a schema; where does that schema come from, and how is it known that it matches what the server actually produces? The next lesson turns the contract into a machine-readable definition, produces a validator from that definition, tests real requests, and catches the case where the schema and the real response diverge.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close