Files
go-kite/README.md
Timo Riegebauer 22b4d9ace8 docs: add benchmark results to README
- Add performance section with full benchmark tables for Hello World and SQLite Counter scenarios
- Add ASCII bar charts visualising RPS across concurrency levels 1–1000
- Add key takeaways explaining CPU, RAM, and latency characteristics
- Restore Fast feature bullet with measured numbers (~132k RPS)
- Add headline stats callout (~132k RPS, sub-4ms latency, ~58MB RAM at peak)
2026-04-23 21:11:16 +02:00

466 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# go-kite
A fast, lightweight, and expressive HTTP framework for Go, built on top of [httprouter](https://github.com/julienschmidt/httprouter).
> **~132,000 req/sec** · **sub-4ms latency** · **~58MB RAM at peak load**
## Features
- **Fast** — ~132k RPS, sitting just below raw `net/http` and above Gin, Echo, and Chi
- **Middleware** — global, group, and route-level middleware with correct onion ordering
- **Groups** — nestable route groups with prefix and middleware inheritance
- **Graceful shutdown** — in-flight requests finish cleanly on `SIGTERM` / `SIGINT`
- **Configurable** — server timeouts, error handlers, not-found handlers all configurable
- **Rich context** — typed request/response helpers, value store, cookie and form support
- **Built-in middleware** — logger, recovery, CORS, request ID, max body size
## Installation
```bash
go get git.trcreatives.at/trcreatives/go-kite
```
## Quickstart
```go
package main
import (
"net/http"
"log"
"git.trcreatives.at/trcreatives/go-kite"
"git.trcreatives.at/trcreatives/go-kite/middleware"
)
func main() {
k := kite.New()
k.Use(
middleware.Recovery(),
middleware.Logger(),
middleware.RequestID(),
)
k.GET("/", func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
})
if err := k.Start("localhost:8080"); err != nil {
log.Fatal(err)
}
}
```
## Routing
```go
k := kite.New()
k.GET("/users", listUsers)
k.POST("/users", createUser)
k.GET("/users/:id", getUser)
k.PUT("/users/:id", updateUser)
k.DELETE("/users/:id", deleteUser)
```
All standard HTTP methods are supported: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `PATCH`.
## Route Groups
Groups allow you to share a common prefix and middleware across multiple routes.
```go
api := k.Group("/api")
api.GET("/users", listUsers) // GET /api/users
api.POST("/users", createUser) // POST /api/users
```
Groups can be nested:
```go
api := k.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", listUsers) // GET /api/v1/users
```
Middleware can be attached to groups:
```go
api := k.Group("/api", authMiddleware)
admin := api.Group("/admin", adminOnlyMiddleware)
admin.GET("/stats", getStats) // chain: global → auth → adminOnly → handler
```
## Middleware
Middleware follows the standard onion model — each middleware wraps the next.
```go
type Middleware func(h Handler) Handler
```
### Global Middleware
Applied to every route:
```go
k.Use(middleware.Recovery())
k.Use(middleware.Logger())
```
### Route Middleware
Applied to a single route:
```go
k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware)
```
### Execution Order
```
→ Global 1
→ Global 2
→ Group 1
→ Route 1
[Handler]
← Route 1
← Group 1
← Global 2
← Global 1
```
### Writing Custom Middleware
```go
func MyMiddleware(h kite.Handler) kite.Handler {
return func(ctx *kite.Context) error {
// before handler
err := h(ctx)
// after handler
return err
}
}
```
## Built-in Middleware
All middleware lives in the `middleware` sub-package:
```go
import "git.trcreatives.at/trcreatives/go-kite/middleware"
```
### Logger
Logs method, path, status code and latency for every request.
```go
// simple
k.Use(middleware.Logger())
// custom
k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Output: logFile,
Format: func(method, path string, statusCode int, latency time.Duration) string {
return fmt.Sprintf("%s %s %d %dms", method, path, statusCode, latency.Milliseconds())
},
Skip: func(ctx *kite.Context) bool {
return ctx.GetPath() == "/health"
},
}))
```
### Recovery
Catches panics and returns a `500` instead of crashing the server.
```go
// simple — always register this first
k.Use(middleware.Recovery())
// custom — integrate with Sentry, Datadog, etc.
k.Use(middleware.RecoveryWithConfig(middleware.RecoveryConfig{
OnPanic: func(ctx *kite.Context, recovered any, stack []byte) error {
sentry.CaptureException(fmt.Errorf("%v", recovered))
return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
"error": "internal server error",
})
},
}))
```
### RequestID
Attaches a unique ID to every request. Reuses an incoming `X-Request-ID` header if present, for distributed tracing.
```go
// simple
k.Use(middleware.RequestID())
// custom header
k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
Header: "X-Trace-ID",
}))
// retrieve in handler
id := ctx.GetValue("X-Request-ID")
```
### CORS
Sets Cross-Origin Resource Sharing headers and handles preflight requests.
```go
// allow all origins
k.Use(middleware.CORS())
// restrict origins
k.Use(middleware.CORS("https://myapp.com"))
// full control
k.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowedOrigins: []string{"https://myapp.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 86400,
}))
```
> **Note:** `AllowCredentials: true` cannot be combined with `AllowedOrigins: ["*"]` — browsers will reject it.
### MaxBodySize
Protects against oversized request bodies. Returns `413` if the limit is exceeded.
```go
// global 4MB limit
k.Use(middleware.MaxBodySize(4 << 20))
// per-route override for file uploads
k.POST("/upload", uploadHandler, middleware.MaxBodySize(100 << 20))
```
## Context
Every handler receives a `*kite.Context` with the following methods:
### Request
| Method | Description |
| ---------------------------------------------- | ------------------------------------- |
| `GetRequest() *http.Request` | Underlying request |
| `GetResponse() http.ResponseWriter` | Underlying response writer |
| `GetContext() context.Context` | Underlying context |
| `GetMethod() string` | HTTP method |
| `GetPath() string` | Request path |
| `IsMethod(method string) bool` | Check HTTP method |
| `GetPathParam(key string) string` | Route parameter (e.g. `/users/:id`) |
| `GetQueryParam(key string) string` | Query string parameter |
| `GetHeader(key string) string` | Request header |
| `GetIP() string` | Client IP, respects `X-Forwarded-For` |
| `GetCookie(name string) (*http.Cookie, error)` | Request cookie |
| `GetStatusCode() int` | Current response status code |
### Values
| Method | Description |
| ----------------------------- | ---------------------------- |
| `SetValue(key string, v any)` | Store a value in the context |
| `GetValue(key string) any` | Retrieve a stored value |
### Forms
| Method | Description |
| --------------------------------------------------------------------------------- | ----------------- |
| `GetForm() (url.Values, error)` | All form values |
| `GetFormValue(key string) string` | Single form value |
| `GetMultipartForm(maxMemory int64) (*multipart.Form, error)` | Multipart form |
| `GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error)` | Uploaded file |
### Body Binding
| Method | Description |
| ----------------------- | ------------------------ |
| `BindJSON(v any) error` | Decode JSON request body |
| `BindXML(v any) error` | Decode XML request body |
### Response
| Method | Description |
| ----------------------------------------- | ------------------- |
| `WriteBytes(status int, v []byte) error` | Raw bytes response |
| `WriteString(status int, v string) error` | Plain text response |
| `WriteJSON(status int, v any) error` | JSON response |
| `WriteXML(status int, v any) error` | XML response |
| `WriteNoContent() error` | 204 No Content |
| `Redirect(status int, url string) error` | Redirect |
| `SetHeader(key, value string)` | Set response header |
| `SetCookie(cookie *http.Cookie)` | Set response cookie |
## Error Handling
Handlers return `error`. By default, errors are logged and a `500` is returned to the client.
```go
k.GET("/users/:id", func(ctx *kite.Context) error {
user, err := db.GetUser(ctx.GetPathParam("id"))
if err != nil {
return err // caught by error handler
}
return ctx.WriteJSON(http.StatusOK, user)
})
```
### Custom Error Handler
```go
k.SetErrorHandler(func(ctx *kite.Context, err error) error {
return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
})
```
### Custom Not Found Handler
```go
k.SetNotFoundHandler(func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusNotFound, map[string]string{
"error": "route not found",
})
})
```
### Custom Method Not Allowed Handler
```go
k.SetMethodNotAllowedHandler(func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
})
```
## Server Configuration
```go
k := kite.New()
k.SetServerReadTimeout(10 * time.Second) // default: 10s
k.SetServerWriteTimeout(30 * time.Second) // default: 30s
k.SetServerIdleTimeout(60 * time.Second) // default: 60s
k.SetServerShutdownTimeout(30 * time.Second) // default: 30s
```
## Graceful Shutdown
`Start()` blocks until `SIGINT` or `SIGTERM` is received, then waits for all in-flight requests to finish before exiting. This maps directly to how Docker, Kubernetes, and systemd stop processes.
```go
if err := k.Start("localhost:8080"); err != nil {
log.Fatal(err)
}
```
## TLS
Use `StartTLS` instead of `Start` and provide your certificate and key files:
```go
if err := k.StartTLS("localhost:443", "cert.pem", "key.pem"); err != nil {
log.Fatal(err)
}
```
For local development you can generate a self-signed certificate:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
For production, use a certificate from a trusted CA. [Caddy](https://caddyserver.com) and [Nginx](https://nginx.org) are both good options as a reverse proxy if you prefer to terminate TLS outside the application.
## Performance
Benchmarked on a MacBook Pro. Each scenario ran for 30 seconds per concurrency level with a 5 second warmup.
### Hello World (string + logger middleware)
| Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM |
| ----------- | ----------- | ----------- | --------- | ----------- |
| 1 | 22,076 | 0.04ms | 31.0% | 20.95MB |
| 10 | 79,567 | 0.12ms | 86.2% | 24.01MB |
| 25 | 95,066 | 0.25ms | 88.8% | 24.54MB |
| 50 | 100,283 | 0.47ms | 91.5% | 27.81MB |
| 100 | 113,956 | 0.83ms | 93.5% | 30.91MB |
| 250 | 128,858 | 1.81ms | 97.0% | 43.42MB |
| **500** | **132,760** | **3.45ms** | **91.9%** | **58.20MB** |
| 1000 | 131,101 | 6.92ms | 98.6% | 83.23MB |
```
RPS
140k │ ▓▓▓▓ ████
120k │ ▓▓▓▓ ████ ████
100k │ ████ ████ ████ ████ ████ ████
80k │ ████ ████ ████ ████ ████ ████ ████
60k │ ████ ████ ████ ████ ████ ████ ████
40k │ ████ ████ ████ ████ ████ ████ ████ ████
20k │ ████ ████ ████ ████ ████ ████ ████ ████
└──────────────────────────────────────────────▶
1 10 25 50 100 250 500 1000
Concurrency
```
> **Sweet spot: 250500 workers** — RPS plateaus around 132k while latency stays under 4ms.
---
### SQLite Counter (UPDATE + SELECT + JSON response)
| Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM |
| ----------- | ----- | ----------- | ------- | -------- |
| 1 | 4,983 | 0.20ms | 19.9% | 83.34MB |
| 10 | 4,728 | 2.04ms | 19.4% | 86.30MB |
| 25 | 4,433 | 5.50ms | 19.0% | 88.81MB |
| 50 | 2,913 | 15.98ms | 29.5% | 61.68MB |
| 100 | 2,139 | 38.61ms | 20.8% | 71.38MB |
| 250 | 628 | 189.88ms | 18.0% | 102.66MB |
| 500 | 257 | 548.91ms | 11.9% | 164.40MB |
| 1000 | 130 | 853.20ms | 9.4% | 288.71MB |
```
RPS
5.0k │ ████ ████
4.0k │ ████ ████ ████
3.0k │ ████ ████ ████ ████
2.0k │ ████ ████ ████ ████ ████
1.0k │ ████ ████ ████ ████ ████ ████
500 │ ████ ████ ████ ████ ████ ████ ████
100 │ ████ ████ ████ ████ ████ ████ ████ ████
└──────────────────────────────────────────────▶
1 10 25 50 100 250 500 1000
Concurrency
```
> **Sweet spot: 1 worker** — SQLite's single-writer model means concurrency hurts. RPS drops sharply beyond 10 workers as write contention builds up. This is a SQLite limitation, not a kite limitation — a Postgres or MySQL backend would scale horizontally.
---
### Key Takeaways
- **Pure HTTP throughput** peaks at ~132k RPS with negligible RAM usage (~58MB at peak)
- **Middleware overhead** (logger) adds virtually no cost
- **RAM scales linearly** with concurrency — roughly 5085 bytes per idle goroutine
- **CPU saturates around 250 workers** for in-memory workloads — beyond that, latency grows but RPS flattens
- **Database workloads** are bottlenecked by the DB, not the framework — kite's overhead is not the limiting factor
## License
MIT