Table of contents
- Introduction
- HTTP Content-Type
- Returning HTTP status codes from gRPC APIs
- Customizing HTTP Error Responses
- Custom HTTP Routes
- In-Process Gateway with DoHTTPtoGRPC
Introduction
ColdBrew is gRPC first, which means that gRPC APIs are the primary APIs and HTTP/JSON APIs are generated from the gRPC APIs. This approach is different from other frameworks where HTTP/JSON APIs are independent from gRPC APIs.
ColdBrew uses grpc-gateway to generate HTTP/JSON APIs from gRPC APIs. It reads protobuf service definitions and generates a reverse-proxy server which translates a RESTful HTTP API into gRPC. This server is generated according to the google.api.http annotations in your service definitions.
To learn more about HTTP to gRPC API mapping please refer to gRPC Gateway mapping examples.
Adding a new API to your service
To add a new API endpoint, you need to add a new method to your service definition and annotate it with the google.api.http annotations. The following example shows how to add a new API endpoint to the [example service]:
syntax = "proto3";
package example.v1;
service MySvc {
....
rpc Upper(UpperRequest) returns (UpperResponse) {
option (google.api.http) = {
post: "/api/v1/example/upper"
body: "*"
};
}
...
}
message UpperRequest{
string msg = 1;
}
message UpperResponse{
string msg = 1;
}
The above example adds a new API endpoint to the service which converts the input string to upper case. The endpoint is available at /api/v1/example/upper on the HTTP port and example.v1.MySvc/Upper on the gRPC port.
Run make generate (for ColdBrew cookiecutter) or protoc/buf with grpc-gateway plugin for others to generate the gRPC and HTTP code.
In your service implement the gRPC server interface
// Upper returns the message in upper case
func (s *svc) Upper(_ context.Context, req *proto.UpperRequest) (*proto.UpperResponse, error) {
return &proto.UpperResponse{
Msg: strings.ToUpper(req.GetMsg()),
}, nil
}
Run your server (make run for ColdBrew cookiecutter) and send a request to the HTTP endpoint:
$ curl -X POST -d '{"msg":"hello"}' -i http://localhost:9091/api/v1/example/upper
HTTP/1.1 200 OK
Content-Type: application/json
Grpc-Metadata-Content-Type: application/grpc
Vary: Accept-Encoding
Date: Sun, 23 Apr 2023 07:48:34 GMT
Content-Length: 15
{"msg":"HELLO"}%
or the gRPC endpoint:
$ grpcurl -plaintext -d '{"msg": "hello"}' localhost:9090 example.v1.MySvc/Upper
{
"msg": "HELLO"
}
HTTP Content-Type
ColdBrew supports multiple content-types for requests and responses. The default content-type is application/json. The following content-types are supported by default:
application/jsonapplication/protoapplication/protobuf
Lets assume the following proto definition:
message EchoRequest{
string msg = 1;
}
message EchoResponse{
string msg = 1;
}
service MySvc {
rpc Echo(EchoRequest) returns (EchoResponse) {
option (google.api.http) = {
post: "/api/v1/example/echo"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Echo endpoint"
description: "Provides an echo reply endpoint."
tags: "echo"
};
}
}
and the following service implementation:
// Echo returns the message with the prefix added
func (s *svc) Echo(_ context.Context, req *proto.EchoRequest) (*proto.EchoResponse, error) {
return &proto.EchoResponse{
Msg: fmt.Sprintf("%s: %s", "echo", req.GetMsg()),
}, nil
}
when Content-Type or Accept is not specified in the request header, the default content-type of application/json is used.
JSON request, JSON response
When we send a curl call to the endpoint, we get the following response:
$ curl -X POST -d '{"msg":"hello"}' -i http://127.0.0.1:9091/api/v1/example/echo
HTTP/1.1 200 OK
Content-Type: application/json
Grpc-Metadata-Content-Type: application/grpc
Vary: Accept-Encoding
Date: Sun, 23 Apr 2023 13:42:37 GMT
Content-Length: 20
{"msg":"echo: hello"}%
JSON request, Proto response
We can send a proto request and get a proto response by specifying the Accept header:
curl -X POST -H 'Accept: application/proto' -d '{"msg":"hello"}' -i http://127.0.0.1:9091/api/v1/example/echo
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Grpc-Metadata-Content-Type: application/grpc
Vary: Accept-Encoding
Date: Sun, 23 Apr 2023 13:46:47 GMT
Content-Length: 12
echo: hello%
Proto request, Proto response
We can send a proto request and get a JSON response by specifying the Content-Type header:
$ echo 'msg: "proto message"' | protoc --encode=EchoRequest proto/app.proto | curl -sS -X POST --data-binary @- -H 'Content-Type: application/proto' -i http://127.0.0.1:9091/api/v1/example/echo
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Grpc-Metadata-Content-Type: application/grpc
Vary: Accept-Encoding
Date: Sun, 23 Apr 2023 14:07:38 GMT
Content-Length: 20
echo: proto message%
When to use proto content type for performance
JSON is the default and best choice for browser clients and debugging. But for service-to-service HTTP calls (or mobile clients that can handle binary), application/proto can significantly reduce latency:
| Aspect | JSON | Proto binary |
|---|---|---|
| Serialization speed | ~3-5x slower (reflection-based, text encoding) | Native proto marshal (or vtprotobuf for up to ~4x faster marshal) |
| Payload size | ~2-3x larger (field names, base64 for bytes, text numbers) | Compact binary (varint, field tags only) |
| CPU cost | Higher (string parsing, escaping, number formatting) | Lower (direct binary read/write) |
| Human-readable | Yes | No (use grpcurl or Swagger UI for debugging) |
To use proto format, set the Content-Type and Accept headers:
# Send proto, receive proto
curl -X POST \
-H 'Content-Type: application/proto' \
-H 'Accept: application/proto' \
--data-binary @request.bin \
http://localhost:9091/api/v1/example/echo
For Go service-to-service calls, use the gRPC client directly instead of HTTP — it already uses proto binary encoding. The HTTP proto content type is most useful for non-gRPC clients (mobile apps, polyglot services) that want binary performance without the gRPC protocol overhead.
ColdBrew supports application/proto and application/protobuf out of the box — no configuration needed. Both map to the same proto binary marshaller.
Returning HTTP status codes from gRPC APIs
Overview
gRPC provides a set of standard response messages that can be used to return errors from gRPC APIs. These messages are defined in the google/rpc/status.proto.
// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs. It is
// used by [gRPC](https://github.com/grpc). Each `Status` message contains
// three pieces of data: error code, error message, and error details.
//
// You can find out more about this error model and how to work with it in the
// [API Design Guide](https://cloud.google.com/apis/design/errors).
message Status {
// The status code, which should be an enum value of
// [google.rpc.Code][google.rpc.Code].
int32 code = 1;
// A developer-facing error message, which should be in English. Any
// user-facing error message should be localized and sent in the
// [google.rpc.Status.details][google.rpc.Status.details] field, or localized
// by the client.
string message = 2;
// A list of messages that carry the error details. There is a common set of
// message types for APIs to use.
repeated google.protobuf.Any details = 3;
}
gRPC status codes and HTTP status codes mapping
gRPC status codes can be easily translated to HTTP status codes. The following table shows the mapping between the canonical error codes and HTTP status codes:
| gRPC status code | HTTP status code |
|---|---|
OK | 200 |
INVALID_ARGUMENT | 400 |
OUT_OF_RANGE | 400 |
FAILED_PRECONDITION | 400 |
PERMISSION_DENIED | 403 |
NOT_FOUND | 404 |
ABORTED | 409 |
ALREADY_EXISTS | 409 |
RESOURCE_EXHAUSTED | 429 |
CANCELLED | 499 |
UNKNOWN | 500 |
UNIMPLEMENTED | 501 |
DEADLINE_EXCEEDED | 504 |
Full list of gRPC status codes can be found in the google/rpc/code.proto file.
Returning errors from RPC
When the service returns an error from the rpc its mapped to http status code 500 by default. To return a different http status code, the service can return a google.rpc.Status message with the appropriate error code. The following example shows how to return a google.rpc.Status message with the INVALID_ARGUMENT error code:
message GetBookRequest {
string name = 1;
}
message GetBookResponse {
Book book = 1;
}
service BookService {
rpc GetBook(GetBookRequest) returns (GetBookResponse) {
option (google.api.http) = {
get: "/v1/{name=books/*}"
};
}
}
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) GetBook(ctx context.Context, req *pb.GetBookRequest) (*pb.Book, error) {
if req.Name == "" {
return nil, status.Errorf(codes.InvalidArgument, "Name argument is required")
}
...
}
This will return a google.rpc.Status message with the INVALID_ARGUMENT error code in HTTP and gRPC:
$ grpcurl -plaintext -d '{"name": ""}' localhost:8080 BookService.GetBook
{
"code": 3,
"message": "Name argument is required"
}
$ curl -X GET -i localhost:8080/v1/books/
HTTP/1.1 400 Bad Request
Content-Type: application/json
Vary: Accept-Encoding
Date: Sun, 23 Apr 2023 06:23:43 GMT
Content-Length: 61
{"code":3,"message":"Name argument is required","details":[]}%
Returning additional error details
The google.rpc.Status message can also be used to return additional error details. The following example shows how to return a google.rpc.Status message with the INVALID_ARGUMENT error code and additional error details:
message GetBookRequest {
string name = 1;
}
message GetBookResponse {
Book book = 1;
}
service BookService {
rpc GetBook(GetBookRequest) returns (GetBookResponse) {
option (google.api.http) = {
get: "/v1/{name=books/*}"
};
}
}
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func (s *server) GetBook(ctx context.Context, req *pb.GetBookRequest) (*pb.Book, error) {
if req.Name == "" {
st := status.New(codes.InvalidArgument, "Name argument is required")
st, _ = st.WithDetails(&errdetails.BadRequest_FieldViolation{
Field: "name",
Description: "Name argument is required",
})
return nil, st.Err()
}
...
}
This will output
$ grpcurl -plaintext -d '{"name": ""}' localhost:8080 BookService.GetBook
{
"code": 3,
"message": "Name argument is required",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "name",
"description": "Name argument is required"
}
]
}
]
}
$ curl -X GET localhost:8080/v1/books/
{
"code": 3,
"message": "Name argument is required",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "name",
"description": "Name argument is required"
}
]
}
]
}
Using ColdBrew errors package
All the above examples can be used with the ColdBrew errors package by using the functions NewWithStatus/WrapWithStatus
import (
"github.com/go-coldbrew/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func (s *server) GetBook(ctx context.Context, req *pb.GetBookRequest) (*pb.Book, error) {
if req.Name == "" {
st := status.New(codes.InvalidArgument, "Name argument is required")
st, _ = st.WithDetails(&errdetails.BadRequest_FieldViolation{
Field: "name",
Description: "Name argument is required",
})
return nil, errors.NewWithStatus("Name argument is required", st)
}
...
}
Using the errors.WrapWithStatus function has the same effect as errors.Wrap but it also sets the status code of the error to the status code of the google.rpc.Status message. Similarly, the errors.NewWithStatus function has the same effect as errors.New but it also sets the status code of the error to the status code of the google.rpc.Status message.
ColdBrew errors package also provides stack trace support for errors, which can make debugging easier. For more information see ColdBrew errors package.
Customizing HTTP Error Responses
By default, grpc-gateway returns errors in the following JSON format:
{
"code": 3,
"message": "Name argument is required",
"details": []
}
You may want to customize this error response structure to match your API conventions, support legacy clients, or provide additional context. grpc-gateway provides the WithErrorHandler option to achieve this.
Custom Error Handler
To customize the error response format, create a custom error handler and pass it to the runtime.NewServeMux():
package main
import (
"context"
"encoding/json"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// CustomError defines your desired error response structure
type CustomError struct {
Error ErrorDetail `json:"error"`
}
type ErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
// CustomErrorHandler handles gRPC errors and writes custom JSON response
func CustomErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
// Extract gRPC status from the error
st, ok := status.FromError(err)
if !ok {
st = status.New(codes.Unknown, err.Error())
}
// Build custom error response
customErr := CustomError{
Error: ErrorDetail{
Code: st.Code().String(),
Message: st.Message(),
},
}
// Set content type and HTTP status code
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(runtime.HTTPStatusFromCode(st.Code()))
// Write the custom JSON response
json.NewEncoder(w).Encode(customErr)
}
Integrating with ColdBrew
In your InitHTTP function, apply the custom error handler to the existing mux before registering your service handlers:
func (s *cbSvc) InitHTTP(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
// Apply custom error handler to the existing mux
runtime.WithErrorHandler(CustomErrorHandler)(mux)
return proto.RegisterMyServiceHandlerFromEndpoint(ctx, mux, endpoint, opts)
}
This works because runtime.ServeMuxOption is defined as func(*ServeMux), allowing you to apply options to an existing mux by calling the option function directly.
Using with runtime.NewServeMux
If you’re managing your own gateway setup (without ColdBrew core), pass the option when creating the ServeMux:
mux := runtime.NewServeMux(
runtime.WithErrorHandler(CustomErrorHandler),
)
Example Response
With the custom error handler above, when your gRPC service returns an InvalidArgument error:
return nil, status.Errorf(codes.InvalidArgument, "Name argument is required")
The HTTP response will be:
$ curl -X GET localhost:8080/v1/books/
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "InvalidArgument",
"message": "Name argument is required"
}
}
Instead of the default format:
{"code":3,"message":"Name argument is required","details":[]}
For more advanced customization options, refer to the grpc-gateway customization guide.
Custom HTTP Routes
ColdBrew is gRPC-first, but sometimes you need HTTP endpoints that don’t map to a gRPC method — webhooks, file uploads, OAuth callbacks, static file serving, or custom REST endpoints.
The grpc-gateway runtime.ServeMux passed to InitHTTP supports custom routes via HandlePath. You can register any HTTP handler alongside your gateway routes:
Basic custom route
func (s *svc) InitHTTP(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
// Register gateway routes (proto-generated)
if err := proto.RegisterMyServiceHandlerFromEndpoint(ctx, mux, endpoint, opts); err != nil {
return err
}
// Custom HTTP routes
if err := mux.HandlePath("POST", "/webhooks/stripe", func(w http.ResponseWriter, r *http.Request, _ map[string]string) {
// Handle Stripe webhook — raw HTTP, no proto marshalling
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !verifyStripeSignature(r.Header.Get("Stripe-Signature"), body) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
processWebhookEvent(body)
w.WriteHeader(http.StatusOK)
}); err != nil {
return err
}
return nil
}
Serving static files or a UI
Use the {path=**} wildcard to catch all sub-paths:
func (s *svc) InitHTTP(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
if err := proto.RegisterMyServiceHandlerFromEndpoint(ctx, mux, endpoint, opts); err != nil {
return err
}
// Serve a React/Vue frontend from embedded files
uiHandler := http.FileServer(http.FS(uiFiles))
if err := mux.HandlePath("GET", "/ui/{path=**}", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
// Strip the /ui prefix
r.URL.Path = "/" + pathParams["path"]
uiHandler.ServeHTTP(w, r)
}); err != nil {
return err
}
return nil
}
OAuth callback
if err := mux.HandlePath("GET", "/auth/callback", func(w http.ResponseWriter, r *http.Request, _ map[string]string) {
code := r.URL.Query().Get("code")
token, err := exchangeCodeForToken(code)
if err != nil {
http.Error(w, "auth failed", http.StatusUnauthorized)
return
}
setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusFound)
}); err != nil {
return err
}
Path parameters
HandlePath supports path parameters using {name} syntax. Parameters are passed in the pathParams map:
if err := mux.HandlePath("GET", "/files/{id}", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
fileID := pathParams["id"]
data, err := s.storage.GetFile(r.Context(), fileID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
}); err != nil {
return err
}
Custom routes registered via HandlePath go through ColdBrew’s HTTP middleware stack (compression, tracing, New Relic) just like gateway routes. They benefit from the same observability without any extra configuration.
For routes that need to bypass the grpc-gateway marshalling entirely (e.g., streaming file uploads), HandlePath gives you raw http.ResponseWriter and *http.Request — no proto encoding/decoding involved.
In-Process Gateway with DoHTTPtoGRPC
By default, ColdBrew’s HTTP gateway connects to the gRPC server via a network hop (TCP or Unix socket). For maximum performance, you can use RegisterHandlerServer instead of RegisterHandlerFromEndpoint to handle HTTP requests in-process — eliminating all network overhead.
The challenge is that RegisterHandlerServer bypasses the gRPC interceptor chain (no logging, tracing, metrics, or panic recovery). ColdBrew’s interceptors.DoHTTPtoGRPC() solves this by wrapping each method call through the full interceptor chain.
How to use
- In
InitHTTP, useRegisterHandlerServerand pass the gRPC server directly:
func (s *svc) InitHTTP(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
return proto.RegisterMyServiceHandlerServer(ctx, mux, s)
}
- Wrap each public gRPC method with
DoHTTPtoGRPC:
func (s *svc) Echo(ctx context.Context, req *proto.EchoRequest) (*proto.EchoResponse, error) {
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return s.echo(ctx, req.(*proto.EchoRequest))
}
r, err := interceptors.DoHTTPtoGRPC(ctx, s, handler, req)
if err != nil {
return nil, err
}
return r.(*proto.EchoResponse), nil
}
func (s *svc) echo(ctx context.Context, req *proto.EchoRequest) (*proto.EchoResponse, error) {
// ... actual implementation ...
}
The public method (Echo) is the wrapper that grpc-gateway and gRPC clients both call. The private method (echo) contains the actual business logic. DoHTTPtoGRPC detects whether the call came via the HTTP gateway (using runtime.RPCMethod) and applies the interceptor chain accordingly.
The interceptor chain is cached on first invocation. All interceptor configuration (AddUnaryServerInterceptor, SetFilterFunc, etc.) must be finalized before the first call to DoHTTPtoGRPC.
When to use
| Approach | Latency | Setup |
|---|---|---|
RegisterHandlerFromEndpoint (default) | ~67µs (TCP) or ~36µs (Unix socket) | Zero code changes |
RegisterHandlerServer + DoHTTPtoGRPC
| ~19µs (in-process) | Per-method wrapper |
Use DoHTTPtoGRPC when HTTP gateway latency is critical and you’re willing to add per-method wrappers. For most services, enabling Unix sockets (DISABLE_UNIX_GATEWAY=false) provides a good balance of performance and simplicity.
Payload size impact
How does payload size affect gateway performance? We benchmarked with realistic proto messages containing nested structs with strings, numbers, maps, repeated fields, and timestamps — at three sizes: 1 item (~200B), 50 items (~10KB), and 500 items (~100KB).
Codec: vtprotobuf vs standard proto
Pure serialization cost, no network (Apple M1 Pro):
| Payload | Proto Marshal | VTProto Marshal | Speedup |
|---|---|---|---|
| 1 item (~200B) | 1.1µs / 17 allocs | 0.28µs / 1 alloc | 4x faster |
| 50 items (~10KB) | 53µs / 801 allocs | 12µs / 1 alloc | 4.3x faster |
| 500 items (~100KB) | 529µs / 8,001 allocs | 122µs / 1 alloc | 4.3x faster |
| Payload | Proto Unmarshal | VTProto Unmarshal | Speedup |
|---|---|---|---|
| 1 item (~200B) | 1.4µs / 40 allocs | 0.58µs / 23 allocs | 2.5x faster |
| 50 items (~10KB) | 69µs / 1,908 allocs | 29µs / 1,107 allocs | 2.4x faster |
| 500 items (~100KB) | 684µs / 19,011 allocs | 290µs / 11,010 allocs | 2.4x faster |
vtprotobuf marshal produces a single allocation regardless of payload size, and is up to ~4x faster. Unmarshal is ~2.4x faster with 40-70% fewer allocations.
Transport: TCP vs Unix socket vs in-process
End-to-end gRPC unary call latency with default proto codec (Apple M1 Pro):
| Payload | TCP | Unix socket | Bufconn (theoretical)* |
|---|---|---|---|
| 1 item (~200B) | 85µs | 48µs | 30µs |
| 50 items (~10KB) | 393µs | 356µs | 295µs |
| 500 items (~100KB) | 2,834µs | 3,061µs | 2,628µs |
*Bufconn is a test utility (google.golang.org/grpc/test/bufconn) — no keepalive, backpressure, or connection lifecycle. These numbers represent a theoretical in-process lower bound, not a production transport. For production in-process calls, use DoHTTPtoGRPC which skips serialization entirely and is even faster.
Key takeaways:
- Transport matters most for small payloads. At 1 item, Unix socket (48µs) vs TCP (85µs) is a 1.8x improvement — the fixed transport overhead dominates when serialization is cheap.
-
Serialization dominates at scale. At 500 items, TCP and Unix socket converge because proto marshal/unmarshal (1.2ms total) dwarfs the ~40µs transport difference. This is where vtprotobuf and in-process calls (
DoHTTPtoGRPC) have the most impact. - vtprotobuf is ColdBrew’s default and provides the biggest single improvement. If you’re not already using generated vtproto files, see the vtprotobuf howto.
-
Both optimizations compound. For maximum throughput: use vtprotobuf (default) + Unix socket for easy wins, or vtprotobuf +
DoHTTPtoGRPCfor latency-critical paths.
These benchmarks measure the gRPC layer only — the HTTP JSON↔proto conversion at the boundary is identical for all approaches. For large responses, consider using application/proto content type to avoid JSON marshalling overhead entirely.
Benchmark source: benchmarks/ — run with cd benchmarks && go test -bench=. -benchmem ./...