Deep Dive: REST API Communication
Server-to-server REST API communication is the backbone of modern distributed systems. Whether you’re orchestrating microservices, integrating third-party services, or building a scalable backend architecture, understanding REST principles and implementation patterns is essential for building robust, maintainable systems.
This guide covers everything from REST fundamentals through production deployment patterns, with practical TypeScript examples you can use immediately.
What is REST?
REST (Representational State Transfer) is an architectural style for designing distributed systems over HTTP. It’s important to understand that REST is not a protocol—it’s a set of constraints that, when applied to HTTP, create predictable, scalable APIs.
REST was formalized by Roy Fielding in 2000 and is built on six constraints:
- Client-Server Architecture — separation of concerns between client and server
- Statelessness — each request contains all information needed to understand and process it
- Uniform Interface — consistent way to communicate with resources
- Cacheability — responses should define themselves as cacheable or not
- Layered System — clients cannot assume direct connection to end server
- Code on Demand (optional) — servers can extend client functionality
The Richardson Maturity Model provides a framework for REST maturity:
- Level 0: POX (Plain Old XML) — single endpoint, RPC-style
- Level 1: Resources — multiple endpoints, but HTTP methods ignored
- Level 2: HTTP Verbs — proper use of GET, POST, PUT, DELETE
- Level 3: HATEOAS — hypermedia as engine of application state
Most production APIs aim for Level 2, with careful architectural decisions around Level 3.
A Typical Server-to-Server REST Exchange
Here’s how two services communicate via REST:
sequenceDiagram
participant ServiceA as Service A (Client)
participant Network as HTTP
participant ServiceB as Service B (Server)
ServiceA->>ServiceA: Prepare request<br/>POST /api/v1/orders<br/>Authorization: Bearer token<br/>Content-Type: application/json
ServiceA->>Network: Send HTTP request
Network->>ServiceB: Route to endpoint handler
ServiceB->>ServiceB: Authenticate<br/>Validate request<br/>Process business logic
ServiceB->>ServiceB: Generate response<br/>200 OK<br/>Content-Type: application/json
ServiceB->>Network: Send HTTP response
Network->>ServiceA: Deliver response body<br/>{ id, status, timestamp }
ServiceA->>ServiceA: Parse JSON<br/>Handle response<br/>Update state
Core Principles
- Resources Over RPC — model your API around nouns (users, orders, payments) not verbs (getUser, createOrder)
- HTTP Methods as Verbs — GET retrieves, POST creates, PUT updates, DELETE removes; these aren’t suggestions
- Statelessness — the server doesn’t maintain session state; each request is independent and complete
- Idempotency — repeating a request should produce the same result; safe for retries without side effects
- Proper Status Codes — use 2xx for success, 3xx for redirection, 4xx for client errors, 5xx for server errors
- Content Negotiation — support multiple formats (JSON, XML) via Accept headers when appropriate
- Versioning — design for API evolution from day one
HTTP Method Semantics and Status Codes
Correct use of HTTP methods is foundational to REST. Each method has specific semantics and expected side effects.
GET — Safe and Idempotent
app.get('/api/v1/orders/:orderId', (req, res) => {
try {
const order = orderService.getOrder(req.params.orderId);
if (!order) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'Order Not Found',
status: 404,
detail: `Order ${req.params.orderId} does not exist`,
});
}
res.set('Cache-Control', 'public, max-age=300');
res.status(200).json(order);
} catch (error) {
res.status(500).json(createErrorResponse(error));
}
});@GetMapping("/api/v1/orders/{orderId}")
public ResponseEntity<?> getOrder(@PathVariable String orderId) {
try {
Order order = orderService.getOrder(orderId);
if (order == null) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "Order Not Found",
"status", 404,
"detail", "Order " + orderId + " does not exist"
));
}
return ResponseEntity.ok()
.header("Cache-Control", "public, max-age=300")
.body(order);
} catch (Exception e) {
return ResponseEntity.status(500).body(createErrorResponse(e));
}
}from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/v1/orders/{order_id}")
async def get_order(order_id: str):
try:
order = order_service.get_order(order_id)
if order is None:
return JSONResponse(status_code=404, content={
"type": "https://api.example.com/errors/not-found",
"title": "Order Not Found",
"status": 404,
"detail": f"Order {order_id} does not exist",
})
return JSONResponse(
status_code=200,
content=order.dict(),
headers={"Cache-Control": "public, max-age=300"},
)
except Exception as e:
return JSONResponse(status_code=500, content=create_error_response(e))[HttpGet("api/v1/orders/{orderId}")]
public async Task<IActionResult> GetOrder(string orderId)
{
try
{
var order = await _orderService.GetOrderAsync(orderId);
if (order == null)
{
return NotFound(new
{
type = "https://api.example.com/errors/not-found",
title = "Order Not Found",
status = 404,
detail = $"Order {orderId} does not exist"
});
}
Response.Headers.CacheControl = "public, max-age=300";
return Ok(order);
}
catch (Exception e)
{
return StatusCode(500, CreateErrorResponse(e));
}
}Characteristics: Safe (no side effects), idempotent, cacheable, should not have a request body.
Common Status Codes:
200 OK— successful retrieval304 Not Modified— resource hasn’t changed since If-Modified-Since header404 Not Found— resource doesn’t exist410 Gone— resource was permanently removed
POST — Creates Resources
app.post('/api/v1/orders', (req, res) => {
try {
const { userId, items, shippingAddress } = req.body;
// Validate request
const validation = validateOrderRequest({ userId, items, shippingAddress });
if (!validation.valid) {
return res.status(400).json({
type: 'https://api.example.com/errors/validation-error',
title: 'Validation Failed',
status: 400,
detail: validation.errors.join('; '),
instance: req.path,
});
}
// Create order (may fail with conflict)
const newOrder = await orderService.createOrder(userId, items, shippingAddress);
// Return 201 Created with Location header
res
.status(201)
.set('Location', `/api/v1/orders/${newOrder.id}`)
.json(newOrder);
} catch (error) {
if (error instanceof ConflictError) {
return res.status(409).json({
type: 'https://api.example.com/errors/conflict',
title: 'Conflict',
status: 409,
detail: error.message,
});
}
res.status(500).json(createErrorResponse(error));
}
});@PostMapping("/api/v1/orders")
public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest body) {
try {
// Validate request
ValidationResult validation = validateOrderRequest(body);
if (!validation.isValid()) {
return ResponseEntity.status(400).body(Map.of(
"type", "https://api.example.com/errors/validation-error",
"title", "Validation Failed",
"status", 400,
"detail", String.join("; ", validation.getErrors())
));
}
Order newOrder = orderService.createOrder(
body.getUserId(), body.getItems(), body.getShippingAddress());
return ResponseEntity.status(201)
.header("Location", "/api/v1/orders/" + newOrder.getId())
.body(newOrder);
} catch (ConflictException e) {
return ResponseEntity.status(409).body(Map.of(
"type", "https://api.example.com/errors/conflict",
"title", "Conflict",
"status", 409,
"detail", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(createErrorResponse(e));
}
}from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter()
class CreateOrderRequest(BaseModel):
user_id: str
items: list
shipping_address: dict
@router.post("/api/v1/orders")
async def create_order(body: CreateOrderRequest, request: Request):
try:
validation = validate_order_request(body)
if not validation.valid:
return JSONResponse(status_code=400, content={
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "; ".join(validation.errors),
"instance": str(request.url.path),
})
new_order = await order_service.create_order(
body.user_id, body.items, body.shipping_address)
return JSONResponse(
status_code=201,
content=new_order.dict(),
headers={"Location": f"/api/v1/orders/{new_order.id}"},
)
except ConflictError as e:
return JSONResponse(status_code=409, content={
"type": "https://api.example.com/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": str(e),
})
except Exception as e:
return JSONResponse(status_code=500, content=create_error_response(e))[HttpPost("api/v1/orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest body)
{
try
{
var validation = ValidateOrderRequest(body);
if (!validation.Valid)
{
return BadRequest(new
{
type = "https://api.example.com/errors/validation-error",
title = "Validation Failed",
status = 400,
detail = string.Join("; ", validation.Errors),
instance = Request.Path.Value
});
}
var newOrder = await _orderService.CreateOrderAsync(
body.UserId, body.Items, body.ShippingAddress);
Response.Headers.Location = $"/api/v1/orders/{newOrder.Id}";
return StatusCode(201, newOrder);
}
catch (ConflictException e)
{
return Conflict(new
{
type = "https://api.example.com/errors/conflict",
title = "Conflict",
status = 409,
detail = e.Message
});
}
catch (Exception e)
{
return StatusCode(500, CreateErrorResponse(e));
}
}Characteristics: Not safe (creates side effects), not idempotent (each request creates new resource), typically has request body.
Common Status Codes:
201 Created— resource successfully created, include Location header with new resource URI202 Accepted— request accepted for async processing, return status URI400 Bad Request— validation failed409 Conflict— request conflicts with current state (duplicate, constraint violation)
PUT — Full Replacement
app.put('/api/v1/orders/:orderId', (req, res) => {
try {
const { items, shippingAddress, status } = req.body;
// Validate complete resource
const validation = validateCompleteOrder({ items, shippingAddress, status });
if (!validation.valid) {
return res.status(400).json({
type: 'https://api.example.com/errors/validation-error',
title: 'Invalid Order',
status: 400,
detail: validation.errors.join('; '),
});
}
// PUT replaces entire resource; fail if missing required fields
const updated = await orderService.replaceOrder(req.params.orderId, {
items,
shippingAddress,
status,
});
if (!updated) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'Order Not Found',
status: 404,
detail: `Order ${req.params.orderId} not found`,
});
}
res.status(200).json(updated);
} catch (error) {
res.status(500).json(createErrorResponse(error));
}
});@PutMapping("/api/v1/orders/{orderId}")
public ResponseEntity<?> replaceOrder(
@PathVariable String orderId,
@RequestBody ReplaceOrderRequest body) {
try {
ValidationResult validation = validateCompleteOrder(body);
if (!validation.isValid()) {
return ResponseEntity.status(400).body(Map.of(
"type", "https://api.example.com/errors/validation-error",
"title", "Invalid Order",
"status", 400,
"detail", String.join("; ", validation.getErrors())
));
}
Order updated = orderService.replaceOrder(orderId, body);
if (updated == null) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "Order Not Found",
"status", 404,
"detail", "Order " + orderId + " not found"
));
}
return ResponseEntity.ok(updated);
} catch (Exception e) {
return ResponseEntity.status(500).body(createErrorResponse(e));
}
}from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter()
class ReplaceOrderRequest(BaseModel):
items: list
shipping_address: dict
status: str
@router.put("/api/v1/orders/{order_id}")
async def replace_order(order_id: str, body: ReplaceOrderRequest):
try:
validation = validate_complete_order(body)
if not validation.valid:
return JSONResponse(status_code=400, content={
"type": "https://api.example.com/errors/validation-error",
"title": "Invalid Order",
"status": 400,
"detail": "; ".join(validation.errors),
})
updated = await order_service.replace_order(order_id, body)
if updated is None:
return JSONResponse(status_code=404, content={
"type": "https://api.example.com/errors/not-found",
"title": "Order Not Found",
"status": 404,
"detail": f"Order {order_id} not found",
})
return JSONResponse(status_code=200, content=updated.dict())
except Exception as e:
return JSONResponse(status_code=500, content=create_error_response(e))[HttpPut("api/v1/orders/{orderId}")]
public async Task<IActionResult> ReplaceOrder(
string orderId, [FromBody] ReplaceOrderRequest body)
{
try
{
var validation = ValidateCompleteOrder(body);
if (!validation.Valid)
{
return BadRequest(new
{
type = "https://api.example.com/errors/validation-error",
title = "Invalid Order",
status = 400,
detail = string.Join("; ", validation.Errors)
});
}
var updated = await _orderService.ReplaceOrderAsync(orderId, body);
if (updated == null)
{
return NotFound(new
{
type = "https://api.example.com/errors/not-found",
title = "Order Not Found",
status = 404,
detail = $"Order {orderId} not found"
});
}
return Ok(updated);
}
catch (Exception e)
{
return StatusCode(500, CreateErrorResponse(e));
}
}Characteristics: Not safe, idempotent (same request produces same result), requires complete resource in body.
Common Status Codes:
200 OK— update successful201 Created— update created new resource (if URI didn’t exist)204 No Content— update successful but no response body400 Bad Request— incomplete or invalid resource
PATCH — Partial Update
app.patch('/api/v1/orders/:orderId', (req, res) => {
try {
const updates = req.body; // Only provided fields
// Validate only the fields being updated
const validation = validateOrderUpdates(updates);
if (!validation.valid) {
return res.status(400).json({
type: 'https://api.example.com/errors/validation-error',
title: 'Invalid Update',
status: 400,
detail: validation.errors.join('; '),
});
}
// Apply partial updates
const updated = await orderService.patchOrder(req.params.orderId, updates);
if (!updated) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'Order Not Found',
status: 404,
detail: `Order ${req.params.orderId} not found`,
});
}
res.status(200).json(updated);
} catch (error) {
res.status(500).json(createErrorResponse(error));
}
});@PatchMapping("/api/v1/orders/{orderId}")
public ResponseEntity<?> patchOrder(
@PathVariable String orderId,
@RequestBody Map<String, Object> updates) {
try {
ValidationResult validation = validateOrderUpdates(updates);
if (!validation.isValid()) {
return ResponseEntity.status(400).body(Map.of(
"type", "https://api.example.com/errors/validation-error",
"title", "Invalid Update",
"status", 400,
"detail", String.join("; ", validation.getErrors())
));
}
Order updated = orderService.patchOrder(orderId, updates);
if (updated == null) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "Order Not Found",
"status", 404,
"detail", "Order " + orderId + " not found"
));
}
return ResponseEntity.ok(updated);
} catch (Exception e) {
return ResponseEntity.status(500).body(createErrorResponse(e));
}
}from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter()
@router.patch("/api/v1/orders/{order_id}")
async def patch_order(order_id: str, updates: dict):
try:
validation = validate_order_updates(updates)
if not validation.valid:
return JSONResponse(status_code=400, content={
"type": "https://api.example.com/errors/validation-error",
"title": "Invalid Update",
"status": 400,
"detail": "; ".join(validation.errors),
})
updated = await order_service.patch_order(order_id, updates)
if updated is None:
return JSONResponse(status_code=404, content={
"type": "https://api.example.com/errors/not-found",
"title": "Order Not Found",
"status": 404,
"detail": f"Order {order_id} not found",
})
return JSONResponse(status_code=200, content=updated.dict())
except Exception as e:
return JSONResponse(status_code=500, content=create_error_response(e))[HttpPatch("api/v1/orders/{orderId}")]
public async Task<IActionResult> PatchOrder(
string orderId, [FromBody] JsonElement updates)
{
try
{
var updateDict = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, object>>(updates.GetRawText())!;
var validation = ValidateOrderUpdates(updateDict);
if (!validation.Valid)
{
return BadRequest(new
{
type = "https://api.example.com/errors/validation-error",
title = "Invalid Update",
status = 400,
detail = string.Join("; ", validation.Errors)
});
}
var updated = await _orderService.PatchOrderAsync(orderId, updateDict);
if (updated == null)
{
return NotFound(new
{
type = "https://api.example.com/errors/not-found",
title = "Order Not Found",
status = 404,
detail = $"Order {orderId} not found"
});
}
return Ok(updated);
}
catch (Exception e)
{
return StatusCode(500, CreateErrorResponse(e));
}
}Characteristics: Not safe, may or may not be idempotent (depends on operation), partial resource in body.
Common Status Codes:
200 OK— patch successful, return updated resource204 No Content— patch successful, no body400 Bad Request— invalid patch operations422 Unprocessable Entity— semantically invalid patch
DELETE — Resource Removal
app.delete('/api/v1/orders/:orderId', (req, res) => {
try {
const deleted = await orderService.deleteOrder(req.params.orderId);
if (!deleted) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'Order Not Found',
status: 404,
detail: `Order ${req.params.orderId} not found`,
});
}
// Common pattern: return 204 No Content or 200 with deleted resource
res.status(204).send();
} catch (error) {
res.status(500).json(createErrorResponse(error));
}
});@DeleteMapping("/api/v1/orders/{orderId}")
public ResponseEntity<?> deleteOrder(@PathVariable String orderId) {
try {
boolean deleted = orderService.deleteOrder(orderId);
if (!deleted) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "Order Not Found",
"status", 404,
"detail", "Order " + orderId + " not found"
));
}
return ResponseEntity.noContent().build(); // 204
} catch (Exception e) {
return ResponseEntity.status(500).body(createErrorResponse(e));
}
}from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
router = APIRouter()
@router.delete("/api/v1/orders/{order_id}")
async def delete_order(order_id: str):
try:
deleted = await order_service.delete_order(order_id)
if not deleted:
return JSONResponse(status_code=404, content={
"type": "https://api.example.com/errors/not-found",
"title": "Order Not Found",
"status": 404,
"detail": f"Order {order_id} not found",
})
return Response(status_code=204)
except Exception as e:
return JSONResponse(status_code=500, content=create_error_response(e))[HttpDelete("api/v1/orders/{orderId}")]
public async Task<IActionResult> DeleteOrder(string orderId)
{
try
{
bool deleted = await _orderService.DeleteOrderAsync(orderId);
if (!deleted)
{
return NotFound(new
{
type = "https://api.example.com/errors/not-found",
title = "Order Not Found",
status = 404,
detail = $"Order {orderId} not found"
});
}
return NoContent(); // 204
}
catch (Exception e)
{
return StatusCode(500, CreateErrorResponse(e));
}
}Characteristics: Not safe, idempotent (deleting already-deleted resource returns same result), typically no request body.
Common Status Codes:
204 No Content— deletion successful, no body200 OK— deletion successful, may return deleted resource404 Not Found— resource didn’t exist (some debate on idempotency here)
API Versioning Strategies
Versioning is essential for managing API evolution without breaking clients. There are three primary strategies:
1. URL Path Versioning
Most explicit and visible. Easy to route and cache differently by version.
app.get('/api/v1/users/:id', v1UserHandler);
app.get('/api/v2/users/:id', v2UserHandler);
// Allows deprecating old versions gradually
app.use('/api/v1/*', (req, res, next) => {
res.set('Sunset', new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toUTCString());
res.set('Deprecation', 'true');
next();
});// Spring Boot — separate controllers per version
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<UserV1> getUser(@PathVariable String id) {
return ResponseEntity.ok(userServiceV1.getUser(id));
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<UserV2> getUser(@PathVariable String id) {
return ResponseEntity.ok(userServiceV2.getUser(id));
}
}
// Deprecation filter for v1
@Component
@Order(1)
public class DeprecationFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (request.getRequestURI().startsWith("/api/v1/")) {
long sunsetMs = System.currentTimeMillis() + 30L * 24 * 3600 * 1000;
response.setHeader("Sunset", new Date(sunsetMs).toInstant().toString());
response.setHeader("Deprecation", "true");
}
chain.doFilter(req, res);
}
}from fastapi import APIRouter, Response
from datetime import datetime, timedelta, timezone
router_v1 = APIRouter(prefix="/api/v1")
router_v2 = APIRouter(prefix="/api/v2")
@router_v1.get("/users/{user_id}")
async def get_user_v1(user_id: str, response: Response):
# Mark v1 as deprecated
sunset = datetime.now(timezone.utc) + timedelta(days=30)
response.headers["Sunset"] = sunset.strftime("%a, %d %b %Y %H:%M:%S GMT")
response.headers["Deprecation"] = "true"
return user_service_v1.get_user(user_id)
@router_v2.get("/users/{user_id}")
async def get_user_v2(user_id: str):
return user_service_v2.get_user(user_id)// ASP.NET Core — Asp.Versioning package
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/users")]
public class UserControllerV1 : ControllerBase
{
[HttpGet("{id}")]
[Obsolete]
public IActionResult GetUser(string id)
{
var sunset = DateTime.UtcNow.AddDays(30).ToString("R");
Response.Headers["Sunset"] = sunset;
Response.Headers["Deprecation"] = "true";
return Ok(_userServiceV1.GetUser(id));
}
}
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/users")]
public class UserControllerV2 : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(string id) => Ok(_userServiceV2.GetUser(id));
}Pros: Clear versioning, easy caching, simple routing Cons: Multiple codebases or significant branching, duplicated code
2. Header-Based Versioning
Less visible in URLs, cleaner endpoint structure.
const apiVersion = (req: Request): string => {
return req.get('API-Version') || req.get('Accept-Version') || '1.0';
};
app.get('/api/users/:id', (req, res) => {
const version = apiVersion(req);
if (version.startsWith('1')) {
return userHandlerV1(req, res);
} else if (version.startsWith('2')) {
return userHandlerV2(req, res);
}
res.status(400).json({
type: 'https://api.example.com/errors/unsupported-version',
title: 'Unsupported API Version',
status: 400,
detail: `API version ${version} is not supported`,
});
});@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable String id,
@RequestHeader(value = "API-Version", defaultValue = "1.0") String apiVersion) {
if (apiVersion.startsWith("1")) {
return ResponseEntity.ok(userServiceV1.getUser(id));
} else if (apiVersion.startsWith("2")) {
return ResponseEntity.ok(userServiceV2.getUser(id));
}
return ResponseEntity.status(400).body(Map.of(
"type", "https://api.example.com/errors/unsupported-version",
"title", "Unsupported API Version",
"status", 400,
"detail", "API version " + apiVersion + " is not supported"
));
}
}from fastapi import APIRouter, Header
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/users/{user_id}")
async def get_user(
user_id: str,
api_version: str = Header(default="1.0", alias="API-Version"),
):
if api_version.startswith("1"):
return user_service_v1.get_user(user_id)
elif api_version.startswith("2"):
return user_service_v2.get_user(user_id)
return JSONResponse(status_code=400, content={
"type": "https://api.example.com/errors/unsupported-version",
"title": "Unsupported API Version",
"status": 400,
"detail": f"API version {api_version} is not supported",
})[ApiController]
[Route("api/users")]
public class UserController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(
string id,
[FromHeader(Name = "API-Version")] string apiVersion = "1.0")
{
if (apiVersion.StartsWith("1"))
return Ok(_userServiceV1.GetUser(id));
if (apiVersion.StartsWith("2"))
return Ok(_userServiceV2.GetUser(id));
return BadRequest(new
{
type = "https://api.example.com/errors/unsupported-version",
title = "Unsupported API Version",
status = 400,
detail = $"API version {apiVersion} is not supported"
});
}
}Pros: Single URL structure, less cluttered API surface Cons: Less visible, harder to cache separately, requires client header knowledge
3. Query Parameter Versioning
Rarely recommended but exists.
app.get('/api/users/:id', (req, res) => {
const version = req.query.v || '1.0';
// Version handling...
});@GetMapping("/api/users/{id}")
public ResponseEntity<?> getUser(
@PathVariable String id,
@RequestParam(defaultValue = "1.0") String v) {
// Version handling...
return ResponseEntity.ok(userService.getUser(id, v));
}from fastapi import APIRouter, Query
router = APIRouter()
@router.get("/api/users/{user_id}")
async def get_user(user_id: str, v: str = Query(default="1.0")):
# Version handling...
return user_service.get_user(user_id, v)[HttpGet("api/users/{id}")]
public IActionResult GetUser(string id, [FromQuery] string v = "1.0")
{
// Version handling...
return Ok(_userService.GetUser(id, v));
}Pros: Simple, backward compatible default Cons: Caching issues, not RESTful, mixing concerns in query params
Recommendation: Use URL path versioning for major versions with clear deprecation timelines. Plan for 2-3 years of support minimum.
Authentication Between Services
Server-to-server communication requires robust authentication. The choice depends on your infrastructure and security requirements.
API Keys (Simple but Limited)
interface ApiKeyCredential {
keyId: string;
secret: string;
}
async function validateApiKey(keyId: string, signature: string, body: string): Promise<boolean> {
const credential = await credentialStore.get(keyId);
if (!credential) return false;
// Create signature: HMAC-SHA256(secret, method + path + timestamp + body)
const expectedSignature = crypto
.createHmac('sha256', credential.secret)
.update(body)
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.use('/api/v1/*', async (req, res, next) => {
const auth = req.get('Authorization');
if (!auth?.startsWith('ApiKey ')) {
return res.status(401).json({
type: 'https://api.example.com/errors/unauthorized',
title: 'Unauthorized',
status: 401,
detail: 'Missing or invalid authorization',
});
}
const [keyId, signature] = auth.slice(7).split(':');
const body = req.body ? JSON.stringify(req.body) : '';
const valid = await validateApiKey(keyId, signature, body);
if (!valid) {
return res.status(401).json({
type: 'https://api.example.com/errors/unauthorized',
title: 'Unauthorized',
status: 401,
detail: 'Invalid credentials',
});
}
req.user = { keyId, authenticated: true };
next();
});@Component
public class ApiKeyFilter extends OncePerRequestFilter {
private final CredentialStore credentialStore;
public ApiKeyFilter(CredentialStore credentialStore) {
this.credentialStore = credentialStore;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("ApiKey ")) {
sendUnauthorized(response, "Missing or invalid authorization");
return;
}
String[] parts = auth.substring(7).split(":", 2);
if (parts.length != 2) { sendUnauthorized(response, "Invalid authorization format"); return; }
String keyId = parts[0];
String signature = parts[1];
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
boolean valid = validateApiKey(keyId, signature, body);
if (!valid) { sendUnauthorized(response, "Invalid credentials"); return; }
SecurityContextHolder.getContext().setAuthentication(
new ApiKeyAuthenticationToken(keyId));
chain.doFilter(request, response);
}
private boolean validateApiKey(String keyId, String signature, String body) {
ApiKeyCredential credential = credentialStore.get(keyId);
if (credential == null) return false;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(credential.getSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(signature.getBytes(), expected.getBytes());
} catch (Exception e) { return false; }
}
private void sendUnauthorized(HttpServletResponse response, String detail) throws IOException {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write(String.format(
"{\"type\":\"https://api.example.com/errors/unauthorized\",\"title\":\"Unauthorized\",\"status\":401,\"detail\":\"%s\"}", detail));
}
}import hmac, hashlib
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
class ApiKeyMiddleware(BaseHTTPMiddleware):
def __init__(self, app, credential_store):
super().__init__(app)
self.credential_store = credential_store
async def dispatch(self, request: Request, call_next):
if not request.url.path.startswith("/api/v1/"):
return await call_next(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("ApiKey "):
return JSONResponse(status_code=401, content={
"type": "https://api.example.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authorization",
})
parts = auth[7:].split(":", 1)
if len(parts) != 2:
return JSONResponse(status_code=401, content={"detail": "Invalid format"})
key_id, signature = parts
body = (await request.body()).decode()
credential = self.credential_store.get(key_id)
if not credential:
return JSONResponse(status_code=401, content={"detail": "Invalid credentials"})
expected = hmac.new(
credential["secret"].encode(), body.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return JSONResponse(status_code=401, content={
"type": "https://api.example.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid credentials",
})
request.state.key_id = key_id
return await call_next(request)public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly ICredentialStore _credentialStore;
public ApiKeyMiddleware(RequestDelegate next, ICredentialStore credentialStore)
{
_next = next;
_credentialStore = credentialStore;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Path.StartsWithSegments("/api/v1"))
{
await _next(context);
return;
}
var auth = context.Request.Headers.Authorization.ToString();
if (!auth.StartsWith("ApiKey "))
{
await WriteUnauthorized(context, "Missing or invalid authorization");
return;
}
var parts = auth[7..].Split(':', 2);
if (parts.Length != 2) { await WriteUnauthorized(context, "Invalid format"); return; }
var (keyId, signature) = (parts[0], parts[1]);
context.Request.EnableBuffering();
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Position = 0;
var credential = await _credentialStore.GetAsync(keyId);
if (credential == null) { await WriteUnauthorized(context, "Invalid credentials"); return; }
var keyBytes = Encoding.UTF8.GetBytes(credential.Secret);
var expected = Convert.ToHexString(
HMACSHA256.HashData(keyBytes, Encoding.UTF8.GetBytes(body))).ToLower();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected)))
{
await WriteUnauthorized(context, "Invalid credentials");
return;
}
context.Items["keyId"] = keyId;
await _next(context);
}
private static async Task WriteUnauthorized(HttpContext context, string detail)
{
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
type = "https://api.example.com/errors/unauthorized",
title = "Unauthorized",
status = 401,
detail
});
}
}Pros: Simple to implement, easy to rotate Cons: Key shared in plaintext (always use HTTPS), no expiration, limited scope
mTLS (Mutual TLS)
Most secure for service-to-service, especially in Kubernetes/container environments.
import https from 'https';
import fs from 'fs';
import { createSecureClient } from './http-client';
// Server setup
const serverOptions = {
key: fs.readFileSync('./certs/server.key'),
cert: fs.readFileSync('./certs/server.crt'),
ca: fs.readFileSync('./certs/ca.crt'),
requestCert: true,
rejectUnauthorized: true,
};
https.createServer(serverOptions, app).listen(3000);
// Client setup for calling other services
export const createMtlsClient = () => {
const tlsOptions = {
key: fs.readFileSync('./certs/client.key'),
cert: fs.readFileSync('./certs/client.crt'),
ca: fs.readFileSync('./certs/ca.crt'),
rejectUnauthorized: true,
};
const agent = new https.Agent(tlsOptions);
return async (url: string, options: RequestInit = {}) => {
return fetch(url, {
...options,
agent,
});
};
};// Spring Boot — configure via application.properties or programmatically
// application.properties:
// server.ssl.key-store=classpath:certs/server.p12
// server.ssl.key-store-password=changeit
// server.ssl.trust-store=classpath:certs/ca.p12
// server.ssl.client-auth=need
// mTLS HTTP client using Apache HttpClient 5
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import javax.net.ssl.*;
@Bean
public CloseableHttpClient mtlsHttpClient() throws Exception {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (var is = new FileInputStream("certs/client.p12")) {
keyStore.load(is, "changeit".toCharArray());
}
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (var is = new FileInputStream("certs/ca.p12")) {
trustStore.load(is, "changeit".toCharArray());
}
SSLContext sslContext = SSLContextBuilder.create()
.loadKeyMaterial(keyStore, "changeit".toCharArray())
.loadTrustMaterial(trustStore, null)
.build();
return HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setSSLSocketFactory(new SSLConnectionSocketFactory(sslContext))
.build())
.build();
}# FastAPI server with mTLS via uvicorn SSL options
# Run: uvicorn main:app --ssl-keyfile certs/server.key --ssl-certfile certs/server.crt --ssl-ca-certs certs/ca.crt
# mTLS HTTP client using httpx
import httpx
import ssl
def create_mtls_client() -> httpx.AsyncClient:
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile="certs/ca.crt")
ssl_context.load_cert_chain(certfile="certs/client.crt", keyfile="certs/client.key")
ssl_context.verify_mode = ssl.CERT_REQUIRED
return httpx.AsyncClient(verify=ssl_context)
# Usage
async def call_service(url: str):
async with create_mtls_client() as client:
response = await client.get(url)
return response.json()// Server: configure in Program.cs
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
httpsOptions.ServerCertificate = new X509Certificate2("certs/server.pfx", "changeit");
});
});
// Client: HttpClient with mTLS
public static HttpClient CreateMtlsClient()
{
var clientCert = new X509Certificate2("certs/client.pfx", "changeit");
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(clientCert);
handler.ServerCertificateCustomValidationCallback = (_, cert, _, _) =>
{
// Validate against CA
var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(new X509Certificate2("certs/ca.crt"));
return chain.Build(cert!);
};
return new HttpClient(handler);
}Pros: Cryptographically secure, mutual verification, no shared secrets Cons: Certificate management overhead, less flexible for third parties, harder debugging
JWT Service Tokens
Token-based, suitable for microservices with central auth server.
interface ServiceToken {
iss: string; // issuer
sub: string; // subject (requesting service)
aud: string; // audience (target service)
iat: number; // issued at
exp: number; // expiration
scope: string[]; // permissions
}
function createServiceToken(requestingService: string, targetService: string): string {
const payload: ServiceToken = {
iss: 'auth.internal',
sub: requestingService,
aud: targetService,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
scope: ['read:users', 'write:orders'],
};
return jwt.sign(payload, process.env.SERVICE_TOKEN_SECRET, {
algorithm: 'HS256',
});
}
// Client side
async function callServiceWithJwt(targetService: string, path: string) {
const token = createServiceToken('user-service', targetService);
return fetch(`https://${targetService}/api/v1${path}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
// Server side verification
app.use('/api/v1/*', (req, res, next) => {
const auth = req.get('Authorization');
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({
type: 'https://api.example.com/errors/unauthorized',
title: 'Unauthorized',
status: 401,
detail: 'Missing bearer token',
});
}
try {
const token = jwt.verify(auth.slice(7), process.env.SERVICE_TOKEN_SECRET, {
algorithms: ['HS256'],
audience: 'user-service',
}) as ServiceToken;
req.user = { serviceId: token.sub, scopes: token.scope };
next();
} catch (error) {
res.status(401).json({
type: 'https://api.example.com/errors/unauthorized',
title: 'Unauthorized',
status: 401,
detail: 'Invalid or expired token',
});
}
});// Spring Boot + io.jsonwebtoken (JJWT)
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
@Service
public class ServiceTokenService {
private final SecretKey key = Keys.hmacShaKeyFor(
System.getenv("SERVICE_TOKEN_SECRET").getBytes(StandardCharsets.UTF_8));
public String createServiceToken(String requestingService, String targetService) {
return Jwts.builder()
.issuer("auth.internal")
.subject(requestingService)
.audience().add(targetService).and()
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3_600_000)) // 1 hour
.claim("scope", List.of("read:users", "write:orders"))
.signWith(key)
.compact();
}
public Claims verifyServiceToken(String token, String expectedAudience) {
return Jwts.parser()
.verifyWith(key)
.requireAudience(expectedAudience)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
// Client side
public ResponseEntity<?> callServiceWithJwt(String targetService, String path) {
String token = serviceTokenService.createServiceToken("user-service", targetService);
return restTemplate.exchange(
"https://" + targetService + "/api/v1" + path,
HttpMethod.GET,
new HttpEntity<>(new HttpHeaders() {{ setBearerAuth(token); }}),
Object.class
);
}
// Server-side verification filter
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
response.sendError(401, "Missing bearer token");
return;
}
try {
Claims claims = serviceTokenService.verifyServiceToken(
auth.substring(7), "user-service");
SecurityContextHolder.getContext().setAuthentication(
new JwtAuthenticationToken(claims));
chain.doFilter(request, response);
} catch (JwtException e) {
response.sendError(401, "Invalid or expired token");
}
}
}# FastAPI + PyJWT
import jwt as pyjwt
from datetime import datetime, timedelta, timezone
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET = os.environ["SERVICE_TOKEN_SECRET"]
security = HTTPBearer()
def create_service_token(requesting_service: str, target_service: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"iss": "auth.internal",
"sub": requesting_service,
"aud": target_service,
"iat": now,
"exp": now + timedelta(hours=1),
"scope": ["read:users", "write:orders"],
}
return pyjwt.encode(payload, SECRET, algorithm="HS256")
async def call_service_with_jwt(target_service: str, path: str):
import httpx
token = create_service_token("user-service", target_service)
async with httpx.AsyncClient() as client:
return await client.get(
f"https://{target_service}/api/v1{path}",
headers={"Authorization": f"Bearer {token}"},
)
# Server-side verification dependency
def verify_service_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
payload = pyjwt.decode(
credentials.credentials,
SECRET,
algorithms=["HS256"],
audience="user-service",
)
return {"service_id": payload["sub"], "scopes": payload.get("scope", [])}
except pyjwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token")// ASP.NET Core + System.IdentityModel.Tokens.Jwt
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
public class ServiceTokenService
{
private readonly SymmetricSecurityKey _key = new(
Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SERVICE_TOKEN_SECRET")!));
public string CreateServiceToken(string requestingService, string targetService)
{
var now = DateTime.UtcNow;
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Iss, "auth.internal"),
new Claim(JwtRegisteredClaimNames.Sub, requestingService),
new Claim(JwtRegisteredClaimNames.Aud, targetService),
new Claim("scope", "read:users write:orders"),
};
var token = new JwtSecurityToken(
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
// Client side
public async Task<HttpResponseMessage> CallServiceWithJwtAsync(
string targetService, string path)
{
var token = _serviceTokenService.CreateServiceToken("user-service", targetService);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return await _httpClient.GetAsync($"https://{targetService}/api/v1{path}");
}
// Server-side: configure JWT middleware in Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SERVICE_TOKEN_SECRET")!)),
ValidateIssuer = true,
ValidIssuer = "auth.internal",
ValidateAudience = true,
ValidAudience = "user-service",
ValidateLifetime = true,
};
});Pros: Stateless, expiring tokens, scoped permissions, can include claims Cons: Requires shared secret or public key infrastructure, token leakage risk
Request and Response Patterns
Consistent patterns make APIs predictable and easier to maintain.
Request Structure
interface ApiRequest<T = any> {
// Standard headers
'Content-Type': 'application/json';
'User-Agent': 'ServiceName/1.0';
'X-Request-Id': string; // For tracing
'Authorization': string;
'Idempotency-Key'?: string; // For safe retries
// Body
body: T;
}
// Making a request
async function createOrder(userId: string, items: OrderItem[]) {
const requestId = generateUUID();
const idempotencyKey = generateUUID();
const response = await fetch('https://orders-service/api/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'Idempotency-Key': idempotencyKey,
'Authorization': `Bearer ${serviceToken}`,
},
body: JSON.stringify({
userId,
items,
metadata: {
requestId,
timestamp: new Date().toISOString(),
},
}),
});
return response.json();
}// Spring Boot WebClient (reactive) for service-to-service calls
import org.springframework.web.reactive.function.client.WebClient;
import java.util.UUID;
@Service
public class OrderClient {
private final WebClient webClient;
private final ServiceTokenService tokenService;
public OrderClient(WebClient.Builder builder, ServiceTokenService tokenService) {
this.webClient = builder.baseUrl("https://orders-service").build();
this.tokenService = tokenService;
}
public Mono<Order> createOrder(String userId, List<OrderItem> items) {
String requestId = UUID.randomUUID().toString();
String idempotencyKey = UUID.randomUUID().toString();
String token = tokenService.createServiceToken("user-service", "orders-service");
return webClient.post()
.uri("/api/v1/orders")
.header("Content-Type", "application/json")
.header("X-Request-Id", requestId)
.header("Idempotency-Key", idempotencyKey)
.header("Authorization", "Bearer " + token)
.bodyValue(Map.of(
"userId", userId,
"items", items,
"metadata", Map.of(
"requestId", requestId,
"timestamp", Instant.now().toString()
)
))
.retrieve()
.bodyToMono(Order.class);
}
}import httpx
import uuid
from datetime import datetime, timezone
async def create_order(user_id: str, items: list) -> dict:
request_id = str(uuid.uuid4())
idempotency_key = str(uuid.uuid4())
token = create_service_token("user-service", "orders-service")
async with httpx.AsyncClient() as client:
response = await client.post(
"https://orders-service/api/v1/orders",
headers={
"Content-Type": "application/json",
"X-Request-Id": request_id,
"Idempotency-Key": idempotency_key,
"Authorization": f"Bearer {token}",
},
json={
"user_id": user_id,
"items": items,
"metadata": {
"request_id": request_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
},
)
response.raise_for_status()
return response.json()public class OrderClient
{
private readonly HttpClient _httpClient;
private readonly ServiceTokenService _tokenService;
public OrderClient(HttpClient httpClient, ServiceTokenService tokenService)
{
_httpClient = httpClient;
_tokenService = tokenService;
}
public async Task<Order?> CreateOrderAsync(string userId, List<OrderItem> items)
{
var requestId = Guid.NewGuid().ToString();
var idempotencyKey = Guid.NewGuid().ToString();
var token = _tokenService.CreateServiceToken("user-service", "orders-service");
var request = new HttpRequestMessage(HttpMethod.Post,
"https://orders-service/api/v1/orders")
{
Headers =
{
{ "X-Request-Id", requestId },
{ "Idempotency-Key", idempotencyKey },
Authorization = new AuthenticationHeaderValue("Bearer", token),
},
Content = JsonContent.Create(new
{
userId,
items,
metadata = new { requestId, timestamp = DateTime.UtcNow }
})
};
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Order>();
}
}Response Structure
interface ApiResponse<T = any> {
// 2xx Success
status: number; // 200, 201, 204
headers: {
'Content-Type': 'application/json';
'Cache-Control': string;
'X-Request-Id': string; // Echo request ID for tracing
'X-RateLimit-Limit': string;
'X-RateLimit-Remaining': string;
'X-RateLimit-Reset': string;
};
body: T;
}
// 2xx response handling
interface Order {
id: string;
userId: string;
items: OrderItem[];
status: 'pending' | 'processing' | 'shipped' | 'delivered';
createdAt: string;
updatedAt: string;
}
const response = await fetch('https://orders-service/api/v1/orders/123', {
headers: { 'Authorization': `Bearer ${token}` },
});
if (response.ok) {
const order: Order = await response.json();
console.log(`Order ${order.id} status: ${order.status}`);
}// Spring Boot — consume and handle a REST response
public Order fetchOrder(String orderId) {
ResponseEntity<Order> response = restTemplate.exchange(
"https://orders-service/api/v1/orders/" + orderId,
HttpMethod.GET,
new HttpEntity<>(new HttpHeaders() {{ setBearerAuth(serviceToken); }}),
Order.class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
Order order = response.getBody();
System.out.println("Order " + order.getId() + " status: " + order.getStatus());
return order;
}
throw new RuntimeException("Unexpected status: " + response.getStatusCode());
}import httpx
async def fetch_order(order_id: str, token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://orders-service/api/v1/orders/{order_id}",
headers={"Authorization": f"Bearer {token}"},
)
if response.is_success:
order = response.json()
print(f"Order {order['id']} status: {order['status']}")
return order
response.raise_for_status()public async Task<Order?> FetchOrderAsync(string orderId, string token)
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.GetAsync(
$"https://orders-service/api/v1/orders/{orderId}");
if (response.IsSuccessStatusCode)
{
var order = await response.Content.ReadFromJsonAsync<Order>();
Console.WriteLine($"Order {order!.Id} status: {order.Status}");
return order;
}
response.EnsureSuccessStatusCode();
return null;
}Error Handling and RFC 7807 Problem Details
Standardized error responses make client error handling consistent and predictable.
// RFC 7807 Problem Details for HTTP APIs
interface ProblemDetail {
type: string; // URI reference to error type documentation
title: string; // Short, human-readable summary
status: number; // HTTP status code
detail?: string; // Human-readable explanation
instance?: string; // URI of the specific problem occurrence
[key: string]: any; // Extension properties for specific error types
}
// Error type examples
const ErrorTypes = {
NOT_FOUND: 'https://api.example.com/errors/not-found',
VALIDATION_ERROR: 'https://api.example.com/errors/validation-error',
CONFLICT: 'https://api.example.com/errors/conflict',
RATE_LIMIT: 'https://api.example.com/errors/rate-limit-exceeded',
SERVICE_UNAVAILABLE: 'https://api.example.com/errors/service-unavailable',
INTERNAL_ERROR: 'https://api.example.com/errors/internal-error',
};
function createProblemResponse(status: number, type: string, title: string, detail?: string): ProblemDetail {
return {
type,
title,
status,
detail,
instance: `${new Date().toISOString()}`,
};
}
// Usage in error handling
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
// Log with request context for debugging
logger.error({
error: err.message,
stack: err.stack,
requestId: req.get('X-Request-Id'),
path: req.path,
method: req.method,
});
// Return standardized error response
const problem = createProblemResponse(
500,
ErrorTypes.INTERNAL_ERROR,
'Internal Server Error',
process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred'
);
res.status(500).json(problem);
});
// Specific error responses
class ValidationError extends Error {
constructor(public errors: string[]) {
super('Validation failed');
}
}
app.post('/api/v1/orders', (req, res) => {
try {
const validation = validateOrderRequest(req.body);
if (!validation.valid) {
throw new ValidationError(validation.errors);
}
// ... process
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({
type: ErrorTypes.VALIDATION_ERROR,
title: 'Validation Failed',
status: 400,
detail: 'Request validation failed',
errors: err.errors,
instance: req.path,
});
}
next(err);
}
});// Spring Boot — RFC 7807 via ProblemDetail (Spring 6+)
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGeneral(
Exception ex, HttpServletRequest request) {
log.error("Unhandled error: path={}, method={}",
request.getRequestURI(), request.getMethod(), ex);
ProblemDetail problem = ProblemDetail.forStatus(500);
problem.setType(URI.create("https://api.example.com/errors/internal-error"));
problem.setTitle("Internal Server Error");
String env = System.getenv("SPRING_PROFILES_ACTIVE");
problem.setDetail("development".equals(env) ? ex.getMessage()
: "An unexpected error occurred");
problem.setInstance(URI.create(request.getRequestURI()));
return ResponseEntity.status(500).body(problem);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ProblemDetail> handleValidation(
ValidationException ex, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatus(400);
problem.setType(URI.create("https://api.example.com/errors/validation-error"));
problem.setTitle("Validation Failed");
problem.setDetail("Request validation failed");
problem.setProperty("errors", ex.getErrors());
problem.setInstance(URI.create(request.getRequestURI()));
return ResponseEntity.status(400).body(problem);
}
}
// Usage in a controller
@PostMapping("/api/v1/orders")
public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest body,
HttpServletRequest request) {
ValidationResult validation = validateOrderRequest(body);
if (!validation.isValid()) {
throw new ValidationException(validation.getErrors());
}
// ... process
return ResponseEntity.status(201).body(newOrder);
}# FastAPI — RFC 7807 problem details via exception handlers
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
class ValidationError(Exception):
def __init__(self, errors: list[str]):
self.errors = errors
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled error: path=%s, method=%s", request.url.path, request.method, exc_info=exc)
import os
detail = str(exc) if os.getenv("ENV") == "development" else "An unexpected error occurred"
return JSONResponse(status_code=500, content={
"type": "https://api.example.com/errors/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": detail,
"instance": str(request.url.path),
})
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
return JSONResponse(status_code=400, content={
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "Request validation failed",
"errors": exc.errors,
"instance": str(request.url.path),
})
@app.post("/api/v1/orders")
async def create_order(body: CreateOrderRequest, request: Request):
validation = validate_order_request(body)
if not validation.valid:
raise ValidationError(validation.errors)
# ... process// ASP.NET Core — RFC 7807 via ProblemDetails middleware (.NET 7+)
// Program.cs
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// GlobalExceptionHandler.cs
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IHostEnvironment _env;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IHostEnvironment env)
{
_logger = logger;
_env = env;
}
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception,
CancellationToken ct)
{
_logger.LogError(exception, "Unhandled error: {Path} {Method}",
context.Request.Path, context.Request.Method);
var problem = new ProblemDetails
{
Type = "https://api.example.com/errors/internal-error",
Title = "Internal Server Error",
Status = 500,
Detail = _env.IsDevelopment() ? exception.Message : "An unexpected error occurred",
Instance = context.Request.Path
};
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(problem, ct);
return true;
}
}
// Validation error in controller
[HttpPost("api/v1/orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest body)
{
var validation = ValidateOrderRequest(body);
if (!validation.Valid)
{
return ValidationProblem(new ValidationProblemDetails(
validation.Errors.ToDictionary(e => "error", e => new[] { e }))
{
Type = "https://api.example.com/errors/validation-error",
Title = "Validation Failed",
Status = 400,
Detail = "Request validation failed",
Instance = Request.Path
});
}
// ... process
return StatusCode(201, newOrder);
}Retry Logic with Exponential Backoff and Jitter
Network failures are inevitable. Implement intelligent retries to handle transient failures gracefully.
interface RetryConfig {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 10000,
backoffMultiplier: 2,
};
// Idempotent status codes that are safe to retry
const IDEMPOTENT_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
// Methods that are safe to retry
const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']);
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function calculateBackoffDelay(attempt: number, config: RetryConfig): number {
// Exponential backoff with jitter to avoid thundering herd
const exponentialDelay = Math.min(
config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt),
config.maxDelayMs
);
// Add random jitter (±25% of exponential delay)
const jitter = exponentialDelay * 0.5 * Math.random();
return exponentialDelay + jitter;
}
async function fetchWithRetry(
url: string,
options: RequestInit = {},
config: RetryConfig = DEFAULT_RETRY_CONFIG
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try {
const response = await fetch(url, {
...options,
// Add timeout to prevent hanging requests
signal: AbortSignal.timeout(30000),
});
// Check if response is retryable
if (IDEMPOTENT_STATUS_CODES.has(response.status) &&
IDEMPOTENT_METHODS.has((options.method || 'GET').toUpperCase())) {
if (attempt < config.maxAttempts - 1) {
const delay = calculateBackoffDelay(attempt, config);
console.warn(
`Request failed with ${response.status}. Retrying after ${delay}ms (attempt ${attempt + 1}/${config.maxAttempts})`
);
await sleep(delay);
continue;
}
}
// Success or non-retryable error
return response;
} catch (error) {
lastError = error as Error;
// Only retry on network errors and timeouts
const isNetworkError = error instanceof TypeError ||
(error instanceof DOMException && error.name === 'AbortError');
if (isNetworkError && attempt < config.maxAttempts - 1) {
const delay = calculateBackoffDelay(attempt, config);
console.warn(
`Network error: ${(error as Error).message}. Retrying after ${delay}ms (attempt ${attempt + 1}/${config.maxAttempts})`
);
await sleep(delay);
continue;
}
throw error;
}
}
throw lastError || new Error('Max retry attempts exceeded');
}
// Usage
async function createOrderWithRetry(userId: string, items: OrderItem[]) {
return fetchWithRetry(
'https://orders-service/api/v1/orders',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': generateUUID(),
'Authorization': `Bearer ${serviceToken}`,
},
body: JSON.stringify({ userId, items }),
},
{
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
}
);
}// Spring Boot + Resilience4j retry
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import java.time.Duration;
import java.util.Set;
@Service
public class RetryableOrderClient {
private static final Set<Integer> RETRYABLE_STATUSES = Set.of(408, 429, 500, 502, 503, 504);
private static final Set<String> IDEMPOTENT_METHODS = Set.of("GET", "PUT", "DELETE", "HEAD");
private final RestTemplate restTemplate;
private final Retry retry = Retry.of("orderService", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(100))
.intervalFunction(attempt -> {
// Exponential backoff with jitter
long base = (long)(100 * Math.pow(2, attempt - 1));
long capped = Math.min(base, 5000);
long jitter = (long)(capped * 0.5 * Math.random());
return capped + jitter;
})
.retryOnException(ex -> ex instanceof IOException || ex instanceof TimeoutException)
.build());
public Order createOrderWithRetry(String userId, List<OrderItem> items) {
return Retry.decorateSupplier(retry, () -> {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Idempotency-Key", UUID.randomUUID().toString());
headers.setBearerAuth(serviceToken);
ResponseEntity<Order> response = restTemplate.exchange(
"https://orders-service/api/v1/orders",
HttpMethod.POST,
new HttpEntity<>(Map.of("userId", userId, "items", items), headers),
Order.class
);
return response.getBody();
}).get();
}
}# FastAPI client + tenacity retry library
import asyncio
import random
import httpx
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type, retry_if_result,
)
RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
IDEMPOTENT_METHODS = {"GET", "PUT", "DELETE", "HEAD"}
def is_retryable_response(response: httpx.Response) -> bool:
return response.status_code in RETRYABLE_STATUSES
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=0.1, max=5.0, jitter=0.5),
retry=(
retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException))
| retry_if_result(is_retryable_response)
),
)
async def fetch_with_retry(
method: str,
url: str,
**kwargs,
) -> httpx.Response:
async with httpx.AsyncClient(timeout=30.0) as client:
return await client.request(method, url, **kwargs)
# Usage
async def create_order_with_retry(user_id: str, items: list) -> dict:
response = await fetch_with_retry(
"POST",
"https://orders-service/api/v1/orders",
headers={
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
"Authorization": f"Bearer {service_token}",
},
json={"user_id": user_id, "items": items},
)
response.raise_for_status()
return response.json()// ASP.NET Core + Polly retry
using Polly;
using Polly.Extensions.Http;
using System.Net;
// Program.cs — register HttpClient with Polly retry
builder.Services.AddHttpClient<OrderClient>()
.AddPolicyHandler(GetRetryPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
var retryableStatuses = new[] {
HttpStatusCode.RequestTimeout,
HttpStatusCode.TooManyRequests,
HttpStatusCode.InternalServerError,
HttpStatusCode.BadGateway,
HttpStatusCode.ServiceUnavailable,
HttpStatusCode.GatewayTimeout,
};
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => retryableStatuses.Contains(msg.StatusCode))
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: (attempt, response, context) =>
{
// Exponential backoff with jitter
double baseMs = 100 * Math.Pow(2, attempt - 1);
double capped = Math.Min(baseMs, 5000);
double jitter = capped * 0.5 * Random.Shared.NextDouble();
return TimeSpan.FromMilliseconds(capped + jitter);
},
onRetryAsync: async (outcome, timespan, attempt, context) =>
{
Console.WriteLine($"Retry {attempt} after {timespan.TotalMilliseconds}ms");
await Task.CompletedTask;
}
);
}
// Usage in OrderClient
public async Task<Order?> CreateOrderWithRetryAsync(string userId, List<OrderItem> items)
{
var request = new HttpRequestMessage(HttpMethod.Post,
"https://orders-service/api/v1/orders")
{
Headers = { { "Idempotency-Key", Guid.NewGuid().ToString() } },
Content = JsonContent.Create(new { userId, items })
};
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Order>();
}Circuit Breaker Pattern
Prevent cascading failures by stopping requests to failing services and gradually recovering.
type CircuitState = 'closed' | 'open' | 'half-open';
interface CircuitBreakerConfig {
failureThreshold: number; // requests before opening
successThreshold: number; // successes before closing (in half-open)
timeout: number; // ms to wait before attempting half-open
}
interface CircuitBreakerMetrics {
failures: number;
successes: number;
lastFailureTime?: number;
totalRequests: number;
}
class CircuitBreaker {
private state: CircuitState = 'closed';
private metrics: CircuitBreakerMetrics = {
failures: 0,
successes: 0,
totalRequests: 0,
};
private lastStateChangeTime = Date.now();
constructor(private config: CircuitBreakerConfig) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (this.shouldAttemptReset()) {
this.state = 'half-open';
this.metrics.successes = 0;
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.metrics.successes++;
this.metrics.totalRequests++;
if (this.state === 'half-open' && this.metrics.successes >= this.config.successThreshold) {
this.state = 'closed';
this.resetMetrics();
}
}
private onFailure(): void {
this.metrics.failures++;
this.metrics.totalRequests++;
this.metrics.lastFailureTime = Date.now();
if (this.metrics.failures >= this.config.failureThreshold) {
this.state = 'open';
this.lastStateChangeTime = Date.now();
}
}
private shouldAttemptReset(): boolean {
const timeSinceLastChange = Date.now() - this.lastStateChangeTime;
return timeSinceLastChange >= this.config.timeout;
}
private resetMetrics(): void {
this.metrics = {
failures: 0,
successes: 0,
totalRequests: 0,
};
}
getState(): CircuitState {
return this.state;
}
getMetrics(): CircuitBreakerMetrics {
return { ...this.metrics };
}
}
// Usage
const orderServiceBreaker = new CircuitBreaker({
failureThreshold: 5,
successThreshold: 2,
timeout: 60000, // 1 minute before half-open attempt
});
async function createOrderWithCircuitBreaker(userId: string, items: OrderItem[]) {
return orderServiceBreaker.execute(() =>
fetchWithRetry('https://orders-service/api/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${serviceToken}`,
},
body: JSON.stringify({ userId, items }),
})
);
}
// Monitor circuit breaker health
setInterval(() => {
const state = orderServiceBreaker.getState();
const metrics = orderServiceBreaker.getMetrics();
if (state !== 'closed') {
console.warn(`Order service circuit breaker: ${state}`, metrics);
}
}, 30000);// Spring Boot + Resilience4j CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;
@Service
public class CircuitBreakerOrderClient {
private final CircuitBreaker circuitBreaker;
private final RestTemplate restTemplate;
public CircuitBreakerOrderClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // open after 50% failure rate
.minimumNumberOfCalls(5) // minimum calls before evaluating
.waitDurationInOpenState(Duration.ofSeconds(60))
.permittedNumberOfCallsInHalfOpenState(2)
.slidingWindowSize(10)
.build();
this.circuitBreaker = CircuitBreakerRegistry.of(config)
.circuitBreaker("orderService");
// Monitor state changes
circuitBreaker.getEventPublisher()
.onStateTransition(event ->
System.out.println("Circuit breaker state: " + event.getStateTransition()));
}
public Order createOrderWithCircuitBreaker(String userId, List<OrderItem> items) {
return circuitBreaker.executeSupplier(() -> {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(serviceToken);
return restTemplate.postForObject(
"https://orders-service/api/v1/orders",
new HttpEntity<>(Map.of("userId", userId, "items", items), headers),
Order.class
);
});
}
}# FastAPI + pybreaker circuit breaker
import pybreaker
import httpx
order_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
listeners=[pybreaker.CircuitBreakerListener()],
)
@order_breaker
async def _call_order_service(user_id: str, items: list) -> dict:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://orders-service/api/v1/orders",
headers={"Authorization": f"Bearer {service_token}"},
json={"user_id": user_id, "items": items},
)
response.raise_for_status()
return response.json()
async def create_order_with_circuit_breaker(user_id: str, items: list) -> dict:
try:
return await _call_order_service(user_id, items)
except pybreaker.CircuitBreakerError:
raise RuntimeError("Circuit breaker is OPEN — order service unavailable")// ASP.NET Core + Polly CircuitBreaker
using Polly;
using Polly.Extensions.Http;
// Program.cs — add circuit breaker to HttpClient
builder.Services.AddHttpClient<OrderClient>()
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(60),
onBreak: (outcome, duration) =>
Console.Warn($"Circuit breaker opened for {duration.TotalSeconds}s"),
onReset: () =>
Console.Info("Circuit breaker reset (closed)"),
onHalfOpen: () =>
Console.Info("Circuit breaker half-open — probing")
);
}
// OrderClient usage — Polly middleware handles the circuit breaker automatically
public async Task<Order?> CreateOrderWithCircuitBreakerAsync(
string userId, List<OrderItem> items)
{
// If circuit is open, Polly throws BrokenCircuitException automatically
var response = await _httpClient.PostAsJsonAsync(
"https://orders-service/api/v1/orders",
new { userId, items });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Order>();
}Rate Limiting
Protect your services from overload and enforce fair usage policies.
interface RateLimitConfig {
maxRequests: number;
windowSizeMs: number;
keyExtractor: (req: Request) => string;
}
interface RateLimitMetrics {
requests: number[];
lastCleanup: number;
}
class SlidingWindowRateLimiter {
private buckets = new Map<string, RateLimitMetrics>();
constructor(private config: RateLimitConfig) {}
private cleanOldRequests(metrics: RateLimitMetrics, now: number): void {
// Remove requests outside the current window
metrics.requests = metrics.requests.filter((timestamp) => now - timestamp < this.config.windowSizeMs);
metrics.lastCleanup = now;
}
isAllowed(key: string): boolean {
const now = Date.now();
let metrics = this.buckets.get(key);
if (!metrics) {
metrics = { requests: [], lastCleanup: now };
this.buckets.set(key, metrics);
}
// Clean old requests
if (now - metrics.lastCleanup > this.config.windowSizeMs) {
this.cleanOldRequests(metrics, now);
}
// Check if allowed
if (metrics.requests.length < this.config.maxRequests) {
metrics.requests.push(now);
return true;
}
return false;
}
getRemainingRequests(key: string): number {
const metrics = this.buckets.get(key);
if (!metrics) return this.config.maxRequests;
this.cleanOldRequests(metrics, Date.now());
return Math.max(0, this.config.maxRequests - metrics.requests.length);
}
getResetTime(key: string): number {
const metrics = this.buckets.get(key);
if (!metrics || metrics.requests.length === 0) return Date.now();
const oldestRequest = metrics.requests[0];
return oldestRequest + this.config.windowSizeMs;
}
}
// Create limiter: 1000 requests per minute per service
const rateLimiter = new SlidingWindowRateLimiter({
maxRequests: 1000,
windowSizeMs: 60000,
keyExtractor: (req) => req.get('X-Service-Id') || req.ip || 'anonymous',
});
// Middleware
app.use('/api/v1/*', (req, res, next) => {
const key = rateLimiter['config'].keyExtractor(req);
const remaining = rateLimiter.getRemainingRequests(key);
const resetTime = rateLimiter.getResetTime(key);
// Set rate limit headers
res.set('X-RateLimit-Limit', '1000');
res.set('X-RateLimit-Remaining', String(remaining));
res.set('X-RateLimit-Reset', String(Math.ceil(resetTime / 1000)));
if (!rateLimiter.isAllowed(key)) {
return res.status(429).json({
type: 'https://api.example.com/errors/rate-limit-exceeded',
title: 'Too Many Requests',
status: 429,
detail: `Rate limit exceeded. Retry after ${Math.ceil((resetTime - Date.now()) / 1000)} seconds`,
retryAfter: Math.ceil((resetTime - Date.now()) / 1000),
});
}
next();
});// Spring Boot + Bucket4j sliding-window rate limiter
import io.github.bucket4j.*;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS = 1000;
private static final Duration WINDOW = Duration.ofMinutes(1);
private Bucket getBucketForKey(String key) {
return buckets.computeIfAbsent(key, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(MAX_REQUESTS, Refill.greedy(MAX_REQUESTS, WINDOW)))
.build()
);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
if (!request.getRequestURI().startsWith("/api/v1/")) {
chain.doFilter(request, response); return;
}
String serviceId = request.getHeader("X-Service-Id");
String key = serviceId != null ? serviceId : request.getRemoteAddr();
Bucket bucket = getBucketForKey(key);
long remaining = bucket.getAvailableTokens();
long resetEpoch = Instant.now().plus(WINDOW).getEpochSecond();
response.setHeader("X-RateLimit-Limit", String.valueOf(MAX_REQUESTS));
response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
response.setHeader("X-RateLimit-Reset", String.valueOf(resetEpoch));
if (!bucket.tryConsume(1)) {
response.setStatus(429);
response.setContentType("application/json");
long retryAfter = WINDOW.getSeconds();
response.getWriter().write(String.format(
"{\"type\":\"https://api.example.com/errors/rate-limit-exceeded\"," +
"\"title\":\"Too Many Requests\",\"status\":429," +
"\"detail\":\"Rate limit exceeded. Retry after %d seconds\"," +
"\"retryAfter\":%d}", retryAfter, retryAfter));
return;
}
chain.doFilter(request, response);
}
}# FastAPI + slowapi sliding-window rate limiter
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
limiter = Limiter(key_func=lambda request: (
request.headers.get("X-Service-Id") or get_remote_address(request)
))
app = FastAPI()
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(status_code=429, content={
"type": "https://api.example.com/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": f"Rate limit exceeded. {exc.detail}",
})
@app.get("/api/v1/orders/{order_id}")
@limiter.limit("1000/minute")
async def get_order(request: Request, order_id: str):
return order_service.get_order(order_id)// ASP.NET Core .NET 7+ built-in rate limiting
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
// Program.cs
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("ApiPolicy", limiterOptions =>
{
limiterOptions.PermitLimit = 1000;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // 10-second segments
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
});
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;
context.HttpContext.Response.ContentType = "application/json";
var retryAfter = context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var retryAfterVal)
? (int)retryAfterVal.TotalSeconds : 60;
context.HttpContext.Response.Headers["X-RateLimit-Limit"] = "1000";
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
context.HttpContext.Response.Headers["Retry-After"] = retryAfter.ToString();
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://api.example.com/errors/rate-limit-exceeded",
title = "Too Many Requests",
status = 429,
detail = $"Rate limit exceeded. Retry after {retryAfter} seconds",
retryAfter
}, token);
};
});
app.UseRateLimiter();
// Apply to controller
[EnableRateLimiting("ApiPolicy")]
[ApiController]
[Route("api/v1")]
public class OrdersController : ControllerBase { }OpenAPI/Swagger Schema-First Development
Define your API contract before implementation. This enables client code generation, documentation, and validation.
# openapi.yaml
openapi: 3.0.0
info:
title: Order Service API
version: 1.0.0
description: Internal API for order management
servers:
- url: https://orders-service/api/v1
description: Production
paths:
/orders:
post:
summary: Create a new order
operationId: createOrder
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- userId
- items
properties:
userId:
type: string
format: uuid
items:
type: array
minItems: 1
items:
$ref: '#/components/schemas/OrderItem'
responses:
'201':
description: Order created
headers:
Location:
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/orders/{orderId}:
get:
summary: Retrieve an order
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Order found
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
Order:
type: object
required:
- id
- userId
- items
- status
- createdAt
properties:
id:
type: string
format: uuid
userId:
type: string
format: uuid
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
status:
type: string
enum: [pending, processing, shipped, delivered]
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
OrderItem:
type: object
required:
- productId
- quantity
- price
properties:
productId:
type: string
format: uuid
quantity:
type: integer
minimum: 1
price:
type: number
format: double
minimum: 0
responses:
ValidationError:
description: Request validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
Unauthorized:
description: Authentication required or failed
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
RateLimited:
description: Too many requests
headers:
X-RateLimit-Limit:
schema:
type: integer
X-RateLimit-Remaining:
schema:
type: integer
X-RateLimit-Reset:
schema:
type: integer
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Pagination Patterns
Handle large result sets efficiently without loading entire collections into memory.
Cursor-Based Pagination (Recommended)
More efficient, stable when data changes during pagination.
interface CursorPaginationParams {
limit: number; // 1-100, default 20
cursor?: string; // opaque token
}
interface CursorPage<T> {
items: T[];
nextCursor?: string; // null if last page
hasMore: boolean;
}
function createCursor(id: string, timestamp: number): string {
const data = `${id}:${timestamp}`;
return Buffer.from(data).toString('base64');
}
function decodeCursor(cursor: string): { id: string; timestamp: number } {
const data = Buffer.from(cursor, 'base64').toString();
const [id, timestamp] = data.split(':');
return { id, timestamp: parseInt(timestamp) };
}
app.get('/api/v1/orders', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
let query = db.orders.orderBy('createdAt', 'desc');
if (cursor) {
const { timestamp } = decodeCursor(cursor);
query = query.where('createdAt', '<', new Date(timestamp));
}
// Fetch limit + 1 to determine if there are more results
const orders = await query.limit(limit + 1).toArray();
const hasMore = orders.length > limit;
const items = orders.slice(0, limit);
const response: CursorPage<Order> = {
items,
hasMore,
nextCursor: hasMore
? createCursor(
items[items.length - 1].id,
items[items.length - 1].createdAt.getTime()
)
: undefined,
};
res.json(response);
});@GetMapping("/api/v1/orders")
public ResponseEntity<CursorPage<Order>> getOrders(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(required = false) String cursor) {
limit = Math.min(limit, 100);
Instant cursorTimestamp = null;
if (cursor != null) {
String decoded = new String(Base64.getDecoder().decode(cursor));
String[] parts = decoded.split(":");
cursorTimestamp = Instant.ofEpochMilli(Long.parseLong(parts[1]));
}
List<Order> orders;
if (cursorTimestamp != null) {
orders = orderRepo.findByCreatedAtBeforeOrderByCreatedAtDesc(
cursorTimestamp, PageRequest.of(0, limit + 1));
} else {
orders = orderRepo.findAllByOrderByCreatedAtDesc(PageRequest.of(0, limit + 1));
}
boolean hasMore = orders.size() > limit;
List<Order> items = orders.stream().limit(limit).toList();
String nextCursor = null;
if (hasMore) {
Order last = items.get(items.size() - 1);
String cursorData = last.getId() + ":" + last.getCreatedAt().toEpochMilli();
nextCursor = Base64.getEncoder().encodeToString(cursorData.getBytes());
}
return ResponseEntity.ok(new CursorPage<>(items, nextCursor, hasMore));
}from fastapi import APIRouter, Query
from typing import Optional
import base64
router = APIRouter()
@router.get("/api/v1/orders")
async def get_orders(
limit: int = Query(default=20, ge=1, le=100),
cursor: Optional[str] = None,
db: Session = Depends(get_db),
):
cursor_timestamp = None
if cursor:
decoded = base64.b64decode(cursor).decode()
_, ts = decoded.split(":", 1)
cursor_timestamp = datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
query = db.query(Order).order_by(Order.created_at.desc())
if cursor_timestamp:
query = query.filter(Order.created_at < cursor_timestamp)
# Fetch limit + 1 to check for more
orders = query.limit(limit + 1).all()
has_more = len(orders) > limit
items = orders[:limit]
next_cursor = None
if has_more:
last = items[-1]
cursor_data = f"{last.id}:{int(last.created_at.timestamp() * 1000)}"
next_cursor = base64.b64encode(cursor_data.encode()).decode()
return {"items": items, "next_cursor": next_cursor, "has_more": has_more}[HttpGet("api/v1/orders")]
public async Task<IActionResult> GetOrders(
[FromQuery] int limit = 20,
[FromQuery] string? cursor = null)
{
limit = Math.Min(limit, 100);
DateTime? cursorTimestamp = null;
if (cursor != null)
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
var parts = decoded.Split(':', 2);
cursorTimestamp = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(parts[1])).UtcDateTime;
}
var query = _db.Orders.OrderByDescending(o => o.CreatedAt).AsQueryable();
if (cursorTimestamp.HasValue)
query = query.Where(o => o.CreatedAt < cursorTimestamp.Value);
// Fetch limit + 1 to check for more
var orders = await query.Take(limit + 1).ToListAsync();
bool hasMore = orders.Count > limit;
var items = orders.Take(limit).ToList();
string? nextCursor = null;
if (hasMore)
{
var last = items[^1];
var cursorData = $"{last.Id}:{new DateTimeOffset(last.CreatedAt).ToUnixTimeMilliseconds()}";
nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(cursorData));
}
return Ok(new { items, nextCursor, hasMore });
}Offset-Based Pagination (Simple but Limited)
Works for smaller datasets, less efficient at scale.
interface OffsetPaginationParams {
limit: number; // 1-100, default 20
offset: number; // 0-based
}
interface OffsetPage<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
app.get('/api/v1/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = Math.max(parseInt(req.query.offset as string) || 0, 0);
const [items, total] = await Promise.all([
db.users.orderBy('createdAt', 'desc').offset(offset).limit(limit).toArray(),
db.users.count(),
]);
const response: OffsetPage<User> = {
items,
total,
limit,
offset,
hasMore: offset + limit < total,
};
res.json(response);
});@GetMapping("/api/v1/users")
public ResponseEntity<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) {
limit = Math.min(limit, 100);
offset = Math.max(offset, 0);
Pageable pageable = PageRequest.of(offset / limit, limit,
Sort.by(Sort.Direction.DESC, "createdAt"));
Page<User> page = userRepo.findAll(pageable);
return ResponseEntity.ok(Map.of(
"items", page.getContent(),
"total", page.getTotalElements(),
"limit", limit,
"offset", offset,
"hasMore", offset + limit < page.getTotalElements()
));
}from fastapi import APIRouter, Query, Depends
from sqlalchemy.orm import Session
router = APIRouter()
@router.get("/api/v1/users")
async def get_users(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
):
total = db.query(User).count()
items = (
db.query(User)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return {
"items": items,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + limit < total,
}[HttpGet("api/v1/users")]
public async Task<IActionResult> GetUsers(
[FromQuery] int limit = 20,
[FromQuery] int offset = 0)
{
limit = Math.Min(limit, 100);
offset = Math.Max(offset, 0);
var total = await _db.Users.CountAsync();
var items = await _db.Users
.OrderByDescending(u => u.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync();
return Ok(new
{
items,
total,
limit,
offset,
hasMore = offset + limit < total
});
}Production Checklist
Before deploying your REST API to production:
Testing & Quality
- Unit tests for all handlers (>80% coverage)
- Integration tests for critical paths (auth, payments, etc.)
- Load testing to verify performance under stress
- Chaos engineering tests for failure scenarios
- Security audit of authentication and data handling
- API contract tests against OpenAPI schema
Observability
- Structured logging with request IDs and correlation IDs
- Request/response time metrics per endpoint
- Error rate monitoring and alerting
- Circuit breaker state changes logged
- Rate limit violations tracked
- Authentication failure logs for investigation
Security
- HTTPS/TLS for all endpoints
- Request validation against schema
- SQL injection prevention (use parameterized queries)
- Rate limiting enforced per service/client
- Authentication verification on every endpoint
- Sensitive data not logged or exposed in errors
- CORS configured appropriately
- Input size limits enforced
Performance
- Response time SLO defined and monitored (p99 < 500ms)
- Database query optimization (indexes, explain plans)
- Connection pooling configured
- Caching strategy for read-heavy endpoints
- Pagination enforced for list endpoints
- Unused query parameters ignored safely
Reliability
- Graceful shutdown handling (stop accepting new requests, finish in-flight)
- Health check endpoint at
/healthor/healthz - Circuit breakers configured for external service calls
- Retry logic with backoff for transient failures
- Deadletter queue for failed async operations
- Database connection failover configured
Documentation
- OpenAPI schema updated and published
- Runbook for common operational tasks
- Onboarding guide for new service consumers
- Architecture diagram of service dependencies
- Deprecation timeline for API versions
- SLA and rate limit documentation
Deployment
- Feature flags for gradual rollout
- Canary deployment to catch issues early
- Rollback plan and procedure
- Blue-green deployment for zero-downtime updates
- Database migrations tested before production
- All environment variables documented
Conclusion
REST APIs for server-to-server communication are deceptively complex. The principles are simple—use HTTP semantics correctly, design around resources, and return standardized responses—but their consistent application across a distributed system determines whether your architecture is robust or brittle.
Key takeaways:
- HTTP semantics matter — GET vs. POST vs. PUT aren’t optional style choices; they describe your API contract
- Plan for failure — Retry logic, circuit breakers, and graceful degradation are non-negotiable in production
- Secure from the start — Authentication and authorization should be considered during design, not bolted on later
- Monitor everything — You can’t debug what you don’t measure; structured logging and metrics are essential
- Version deliberately — API changes happen; plan for supporting multiple versions gracefully
Apply these patterns consistently, and you’ll build APIs that scale, maintain, and evolve without constant firefighting.