Skip to content

REST & gRPC — Practical

REST: standard problem-detail error response

Section titled “REST: standard problem-detail error response”
{
"type": "https://api.example.com/errors/invalid-email",
"title": "Invalid email",
"status": 422,
"detail": "Email 'foo@' is not valid",
"instance": "/users",
"fieldErrors": [{ "field": "email", "code": "format" }]
}
{
"data": [ { ... }, { ... } ],
"page": {
"next_cursor": "eyJjcmVhdGVkX2F0IjogIjIwMjYtMDUtMDFUMTI6MDA6MDBaIn0=",
"has_more": true
}
}
// Express handler
app.get('/posts', async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 20), 100);
const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;
const rows = await db.posts.list({ limit: limit + 1, cursor });
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
res.json({
data: rows,
page: { next_cursor: hasMore ? encodeCursor(rows.at(-1)!) : null, has_more: hasMore },
});
});
app.get('/users/:id', async (req, res) => {
const u = await db.users.find(req.params.id);
const etag = `"${hash(u.updatedAt + u.id)}"`;
if (req.headers['if-none-match'] === etag) return res.status(304).end();
res.set('ETag', etag).json(u);
});
import * as grpc from '@grpc/grpc-js';
import { UsersService } from './gen/users';
const server = new grpc.Server();
server.addService(UsersService, {
async get(call, cb) {
try {
const u = await db.users.find(call.request.id);
if (!u) return cb({ code: grpc.status.NOT_FOUND, message: 'user' });
cb(null, u);
} catch (e: any) {
cb({ code: grpc.status.INTERNAL, message: e.message });
}
},
list(call) {
db.users.stream().on('data', u => call.write(u)).on('end', () => call.end());
},
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => server.start());
const client = new UsersClient('users:50051', grpc.credentials.createInsecure());
const deadline = new Date(Date.now() + 1500);
client.get({ id: '123' }, { deadline }, (err, user) => { ... });
const call = client.upload((err, summary) => console.log(summary));
for (const chunk of chunks) call.write({ data: chunk });
call.end();
const call = client.chat();
call.on('data', msg => console.log('recv', msg));
call.write({ text: 'hi' });
process.stdin.on('data', d => call.write({ text: d.toString() }));
openapi: 3.0.3
info: { title: Users, version: '1.0' }
paths:
/users/{id}:
get:
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } }
'404': { description: not found }
components:
schemas:
User:
type: object
required: [id, email]
properties:
id: { type: string }
email: { type: string, format: email }
createdAt: { type: string, format: date-time }
const idem = async (req, res, next) => {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'missing idempotency-key' });
const cached = await redis.get(`idem:${req.path}:${key}`);
if (cached) {
res.set('Idempotent-Replay', '1');
return res.json(JSON.parse(cached));
}
res.locals.idemKey = key;
next();
};
app.post('/payments', idem, async (req, res) => {
const result = await processPayment(req.body);
await redis.set(`idem:${req.path}:${res.locals.idemKey}`, JSON.stringify(result), 'EX', 86400);
res.json(result);
});
/v1/users (frozen)
/v2/users (current)

Or content-negotiated:

GET /users
Accept: application/vnd.acme.v2+json
  • buf — protobuf linter, breaking-change detector, codegen.
  • grpcurlgrpcurl -plaintext localhost:50051 list.
  • ghz — gRPC load test.
  • k6 / wrk / autocannon — REST load test.
  • httpie — friendlier curl.
  • OpenAPI generator — generate clients/servers in many langs.
  • Postman / Insomnia — both support REST and gRPC.
  • Envoy — gRPC-Web bridge, retries, timeouts, metrics.
  • Forgetting deadline on gRPC call → caller hangs forever.
  • gRPC error from interceptor doesn’t include trailers if framing wrong.
  • Returning protobuf default values means “field not set” — use optional for nullable scalars.
  • REST API growing optional query params indefinitely — use POST with body for complex search.
  • Logging bearer tokens — strip auth headers before logging.