C.W.K.
Stream
Lesson 06 of 06 · published

API Gateways & Versioning Lifecycles — The Long Game

~10 min · production, api-gateway, versioning, deprecation

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Once your API is in production with real consumers, you have a new set of problems: cross-cutting policy (auth, rate limit, observability), version evolution (without breaking integrations), and deprecation discipline (telling consumers what's going away). Gateways handle the first; lifecycle hygiene handles the rest."

What an API Gateway Does

An API gateway sits in front of one or more services and handles the cross-cutting concerns every API needs but no application should re-implement per service:

  • Auth verification — validates Bearer tokens, JWT signatures, API keys before requests reach your app.
  • Rate limiting — per-user, per-API-key, per-endpoint limits enforced centrally.
  • Request/response transformation — rename headers, rewrite paths, modify payloads for legacy compatibility.
  • Observability — log every request, emit metrics, trace propagation, all without app changes.
  • Version routing — route /v1/* to one service, /v2/* to another.
  • TLS termination — handle certificate management at the edge; app receives plain HTTP.
  • Caching — gateway-level response caching for hot endpoints.

Major options: Kong (open-source + enterprise), AWS API Gateway, Azure API Management, Google Apigee, Cloudflare Workers + Cache, Tyk. Pick by cloud + scale + feature set.

The Single-Service Alternative

For small services or solo development, a gateway is overkill. Cross-cutting concerns can live in middleware (FastAPI's add_middleware, Express's app.use). The decision flips when:

  • You have multiple services and want consistent policy across them.
  • You want to swap implementations (replace a Python service with a Go service) without changing client URLs.
  • You need centralized API key management across many endpoints.
  • Your auth/rate-limit policies are complex enough to deserve dedicated software.

Start without a gateway; add one when the duplication of cross-cutting code across services becomes the bigger pain.

Versioning Lifecycles — The Long-Term Game

Once you've shipped an API to real consumers, each version is a commitment that takes years to retire. A healthy lifecycle:

  1. Beta — early access, possibly breaking changes. Document the instability; expect early adopters only.
  2. Stable — committed contract. Additive changes only; breaking changes go to the next major version.
  3. Deprecated — signaled via Deprecation: <date> response header months before sunset. Still works; consumers should migrate.
  4. Sunset announcedSunset: <date> header on every response months in advance. The specific date the endpoint will stop responding.
  5. Retired — endpoint returns 410 Gone or redirects to the successor version (308 with Location).

Stripe runs this playbook expertly: every version has a date stamp, every change has a migration guide, every retired endpoint returns 410 with a clear error message pointing at the new version. Consumers always know where they stand.

An API is a commitment, not just code. Once consumers integrate, retiring a version costs them engineering time. The cost is real; respect it with predictable lifecycle policy, advance notice, and clear migration paths. Stripe and Twilio's reputations are built on this.

The Sunset Header in Practice

# Endpoint still working but marked for retirement
GET /v1/users HTTP/1.1

HTTP/1.1 200 OK
Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset: Wed, 01 Jul 2026 00:00:00 GMT
Link: </v2/users>; rel="successor-version"
Content-Type: application/json

[...]

# After Sunset date — endpoint returns 410
GET /v1/users HTTP/1.1

HTTP/1.1 410 Gone
Link: </v2/users>; rel="successor-version"
Content-Type: application/json

{"error":{"code":"endpoint_retired","message":"Use /v2/users; see https://docs.example.com/migrate-v1-v2"}}

Headers signal machine-readable; the body carries the human-readable migration breadcrumb.

cwkPippa's Lifecycle Reality

cwkPippa hasn't formal-versioned its API (single-tenant, two-user) — every change ships in lockstep with the frontend. No gateway either; the FastAPI middleware handles auth/CORS/correlation IDs in-process. If we ever ship a public API, the day a third party integrates is the day we formalize: pick a version prefix (/v1/), implement Sunset/Deprecation headers from day one, document the migration runbook before shipping v2. Stripe's playbook is the gold standard; copy it.

Code

Kong: cross-cutting policy in declarative config·yaml
# Kong Gateway — declarative config example
_format_version: '3.0'

services:
  - name: users-api
    url: http://users-service:8000  # the actual backend
    routes:
      - name: users-v1
        paths: [/v1/users]
      - name: users-v2
        paths: [/v2/users]
    plugins:
      - name: jwt                # validate JWT before reaching backend
      - name: rate-limiting
        config:
          minute: 100
          policy: redis           # distributed rate limit
      - name: correlation-id
        config:
          header_name: X-Request-ID
      - name: response-transformer
        config:
          add:
            headers: ['X-API-Version: 2']

# Kong absorbs auth, rate limit, correlation, and header injection.
# The backend Python/Node app receives a pre-authenticated request
# with X-Request-ID set; doesn't reimplement any of it.
FastAPI: lifecycle headers + 410 after sunset·python
# FastAPI — Sunset / Deprecation headers + 410 after retirement
from datetime import datetime, timezone
from fastapi import FastAPI, Response, HTTPException, status

app = FastAPI()

DEPRECATION_DATE = 'Sun, 01 Jan 2026 00:00:00 GMT'
SUNSET_DATE      = 'Wed, 01 Jul 2026 00:00:00 GMT'
SUNSET_TS        = datetime(2026, 7, 1, tzinfo=timezone.utc)

@app.get('/v1/users')
async def v1_users(response: Response):
    if datetime.now(timezone.utc) >= SUNSET_TS:
        # Past sunset — return 410 with migration breadcrumb
        raise HTTPException(
            status.HTTP_410_GONE,
            detail={
                'code': 'endpoint_retired',
                'message': 'Use /v2/users; see https://docs.example.com/migrate-v1-v2',
            },
            headers={'Link': '</v2/users>; rel="successor-version"'},
        )

    # Still working — surface sunset signals
    response.headers['Deprecation'] = DEPRECATION_DATE
    response.headers['Sunset']      = SUNSET_DATE
    response.headers['Link']        = '</v2/users>; rel="successor-version"'
    return [{'id': 'u_42', 'name': 'Pippa'}]

@app.get('/v2/users')
async def v2_users():
    # New version — same data, richer shape
    return [{'id': 'u_42', 'display_name': 'Pippa', 'role': 'daughter'}]
Client: log Deprecation + Sunset headers for on-call visibility·python
# Client — watch for Sunset headers and log warnings
import httpx, logging
from datetime import datetime

log = logging.getLogger(__name__)

def call_api(url: str):
    resp = httpx.get(url)
    # Surface deprecation/sunset signals so on-call sees them
    if 'Deprecation' in resp.headers:
        log.warning('deprecated endpoint', extra={'url': url, 'deprecation': resp.headers['Deprecation']})
    if 'Sunset' in resp.headers:
        sunset = resp.headers['Sunset']
        link = resp.headers.get('Link', '')
        log.warning('sunset scheduled', extra={'url': url, 'sunset': sunset, 'successor': link})
    return resp.json()

External links

Exercise

Build a FastAPI app with /v1/users (deprecation-flagged with 6 months until sunset) and /v2/users (the successor, with a richer schema). Add Deprecation + Sunset + Link headers on every /v1/users response. Write a Python client that calls /v1/users and emits a structured log warning when it sees the Sunset header. Then deliberately set the sunset date in the past on the server and watch the /v1/users endpoint return 410 with a migration link. Bonus: research one production API's deprecation policy (Stripe, Twilio, GitHub all publish theirs) and compare it to yours.
Hint
The Sunset header value is an HTTP-date (RFC 7231 format): use (datetime.now(timezone.utc) + timedelta(days=180)).strftime('%a, %d %b %Y %H:%M:%S GMT'). The 410 logic is just if datetime.now() >= sunset_ts: raise 410. The client warning logging closes the loop — production engineers grep logs for 'deprecated' or 'sunset' to find which integrations need migrating before their dependencies break. Stripe's docs are a particularly good model — copy their structure.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.