This episode tidies up the failed-response side: HTTPError and a custom error handler, the RFC 9457 Problem Details format for consistent API errors, structured logging with slog through RequestLogger, and log integration with aggregators like Loki and ELK.

A healthy API doesn't just handle success — it designs for failure. API consumers need to know what went wrong, why, and how to fix it, in a consistent format. On the other side, developers need logs that machines can search and analyze.
Episode 11 tidies up both sides: HTTPError and a custom error handler, the RFC 9457 Problem Details format for API errors, structured logging with slog via RequestLogger, and log aggregator integration.
Echo represents errors as echo.HTTPError — an error that carries a status code, a message, and extra payload:
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "user tidak ditemukan")
}When a handler returns echo.HTTPError, Echo automatically writes the status code and message to the response. The handler doesn't need to know HTTP details — just return the error.
HTTPError has a Details field for contextual data, and Message can be filled with complex structures:
err := echo.NewHTTPError(http.StatusBadRequest)
err.Message = "validasi gagal"
err.Details = []string{"email harus format valid", "nama minimal 3 karakter"}
return errThe Details field is serialized into the response, giving clients information they can use to fix the request.
Every error — from handlers, binding, validators, or the router — eventually passes through one point: e.HTTPErrorHandler. Replace it with your own implementation for full control:
e.HTTPErrorHandler = func(err error, c echo.Context) {
he, ok := err.(*echo.HTTPError)
if !ok {
he = echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
if err := c.JSON(he.Code, he.Message); err != nil {
e.Logger.Error("gagal menulis respons error", "err", err)
}
}Non-HTTP errors are wrapped into 500 so internal information doesn't leak to the client. Logging of the original error details is done separately, not sent to the response.
RFC 9457 defines a consistent format for HTTP errors: a single JSON object with standard members like type, title, status, detail, and instance:
{
"type": "https://example.com/problems/validation",
"title": "Validasi gagal",
"status": 400,
"detail": "Satu atau lebih field tidak valid",
"instance": "/api/v1/users",
"errors": ["email harus format valid"]
}Implement it in the custom error handler by mapping HTTPError to a Problem Details structure:
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
Instance string `json:"instance"`
}
e.HTTPErrorHandler = func(err error, c echo.Context) {
he, ok := err.(*echo.HTTPError)
if !ok {
he = echo.NewHTTPError(http.StatusInternalServerError)
}
problem := Problem{
Type: "https://example.com/problems/" + http.StatusText(he.Code),
Title: http.StatusText(he.Code),
Status: he.Code,
Detail: fmt.Sprint(he.Message),
Instance: c.Path(),
}
if err := c.JSON(he.Code, problem); err != nil {
e.Logger.Error("gagal menulis problem", "err", err)
}
}With this format, all your API errors share the same schema — easy for clients to understand and easy to map into documentation.
In episode 3 you already used RequestLogger. In production, set the slog level and output handler according to the environment:
level := slog.LevelInfo
if cfg.Environment == "development" {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})))NewJSONHandler produces JSON logs that are easy for machines to parse. In development, LevelDebug gives more visibility.
Leverage slog inside handlers to record domain-specific context:
slog.Info("user dibuat",
"user_id", user.ID,
"by", c.Get("user_id"),
"trace_id", c.Response().Header().Get(echo.HeaderXRequestID),
)Make it a habit to include a request_id in every log so a single request can be traced end to end — the foundation of observability in episode 18.
JSON logs on stdout are ready to be directed to an aggregator like Loki or ELK without extra code:
services:
api:
image: belajar-echo:latest
logging:
driver: json-file
promtail:
image: grafana/promtail:latest
volumes:
- /var/log:/var/log
command: -config.file=/etc/promtail/config.yamlBecause the logs are already structured, the aggregator can build labels directly from fields like status and latency without manual parsing.
Episode 11 tidies up failed responses and the application's digital trail: HTTPError becomes the central error language, a custom error handler controls the final response, RFC 9457 Problem Details gives a consistent error schema, slog produces structured logs, and aggregators receive those logs without any code changes.
Key takeaways:
HTTPError carries the status code and message.e.HTTPErrorHandler is the single control point for every error.slog with a JSON handler produces machine-ready logs.request_id in every log for tracing.In episode 12 next, we'll discuss context, timeout & concurrency — c.Request().Context() for values and cancellation, goroutines inside handlers, context timeouts, state synchronization with mutex, and safe patterns when sharing data between requests.