Skip to content

Commit 87d00f8

Browse files
feat(serve): make the API write timeout configurable (kenn-io#1148)
## What Adds a `--write-timeout` duration flag to `serve` and `pg serve`, making the API write deadline configurable. The default is unchanged at 30s. A non-positive value disables the deadline. ## Why The write deadline was hardcoded at 30s. On large shared datasets the full-history analytics aggregates (heatmap, activity, usage summary) can exceed it and return `503` with `{"error":"request timed out"}`, which surfaces as blank "request timed out" dashboard panels. Operators had no way to raise it. Relates to kenn-io#1147. ## Where to look - `internal/config/config.go` — the flag is registered on both the `flag` and `pflag` serve flag sets (so `serve` and `pg serve` both accept it) and mapped to `Config.WriteTimeout` in `applyFlagValue`, mirroring `events-coalesce-interval`. - `internal/server/middleware.go` — the standard-handler timeout wrapper now bypasses `http.TimeoutHandler` when the timeout is non-positive, so `0` disables the deadline instead of firing immediately. The typed (Huma) API path already had this guard; this brings the two paths into agreement. - `docs/remote-access.md` — documents raising the timeout for slow aggregates and the flag reference row. ## Tradeoffs and limitations - Raising the timeout treats the symptom. When aggregates are slow enough to need a large value, the underlying database-side cost is usually the real fix — for a multi-tenant read role, a set-based row-level-security predicate rather than a per-row function call. The docs note points there; #1 has the detail. - Flag only; no new config-file or environment key was added, consistent with the neighboring serve duration flags. Co-authored-by: TechnoPhobe01 <Technophobe01@users.noreply.github.com>
1 parent 2a2c8a5 commit 87d00f8

5 files changed

Lines changed: 105 additions & 0 deletions

File tree

docs/remote-access.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,31 @@ localStorage.removeItem("agentsview-server-url")
367367
location.reload()
368368
```
369369

370+
### Slow Aggregates Behind A Proxy
371+
372+
API responses have a write deadline; a request that exceeds it returns `503`
373+
with a `{"error":"request timed out"}` body, and affected dashboard panels show
374+
"request timed out". The default is 30 seconds, which is comfortable for local
375+
archives but can be tight for large shared datasets — heatmap, activity, and
376+
usage summaries scan the full message history.
377+
378+
Raise the deadline with `--write-timeout` (a Go duration). The flag is available
379+
on both the local SQLite server and the PostgreSQL read server:
380+
381+
```bash
382+
# Local SQLite server
383+
agentsview serve --write-timeout 120s
384+
385+
# PostgreSQL-backed read server (the multi-tenant / large-dataset case)
386+
agentsview pg serve --write-timeout 120s
387+
```
388+
389+
Set it to `0` to disable the deadline entirely. If aggregates are slow enough to
390+
need a large timeout, that usually points at a database-side cost worth
391+
investigating first — for a multi-tenant read role, confirm any row-level
392+
security policy is set-based (`session_id IN (SELECT ...)`) rather than a
393+
per-row function call, which the query planner cannot hoist into a single join.
394+
370395
## Managed Caddy Mode
371396

372397
AgentsView can manage a [Caddy](https://caddyserver.com) reverse proxy for
@@ -438,6 +463,7 @@ Changes that affect bind or auth behavior may require a server restart.
438463
| `--require-auth` | `false` | Require a bearer token for API requests |
439464
| `--public-url` | | Public URL for hostname or proxy access |
440465
| `--public-origin` | | Trusted browser origin (repeatable/comma-separated) |
466+
| `--write-timeout` | `30s` | API response write deadline; `0` disables it |
441467
| `--proxy` | | Managed proxy mode (`caddy`) |
442468
| `--caddy-bin` | `caddy` | Caddy binary path |
443469
| `--proxy-bind-host` | `127.0.0.1` | Interface for managed proxy |

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,10 @@ func RegisterServeFlags(fs *flag.FlagSet) {
14811481
"events-coalesce-interval", 10*time.Second,
14821482
"Minimum interval between SSE data_changed broadcasts (0 disables coalescing)",
14831483
)
1484+
fs.Duration(
1485+
"write-timeout", 30*time.Second,
1486+
"Max time to write an API response before a 503 request-timed-out; raise for slow aggregates over large datasets (0 disables)",
1487+
)
14841488
}
14851489

14861490
// RegisterServePFlags registers serve-command flags on fs.
@@ -1545,6 +1549,10 @@ func RegisterServePFlags(fs *pflag.FlagSet) {
15451549
"events-coalesce-interval", 10*time.Second,
15461550
"Minimum interval between SSE data_changed broadcasts (0 disables coalescing)",
15471551
)
1552+
fs.Duration(
1553+
"write-timeout", 30*time.Second,
1554+
"Max time to write an API response before a 503 request-timed-out; raise for slow aggregates over large datasets (0 disables)",
1555+
)
15481556
}
15491557

15501558
// applyFlags copies explicitly-set flags from fs into cfg.
@@ -1604,6 +1612,10 @@ func applyFlagValue(cfg *Config, name, value string) {
16041612
if d, err := time.ParseDuration(value); err == nil {
16051613
cfg.EventsCoalesceInterval = d
16061614
}
1615+
case "write-timeout":
1616+
if d, err := time.ParseDuration(value); err == nil {
1617+
cfg.WriteTimeout = d
1618+
}
16071619
case "pg":
16081620
// Read-routing only. The CLI resolver combines this flag
16091621
// with cfg.PG from env/config and does not persist a new

internal/config/config_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,35 @@ func loadConfigFromPFlags(t *testing.T, args ...string) (Config, error) {
222222
return LoadPFlags(fs)
223223
}
224224

225+
func TestLoad_WriteTimeout(t *testing.T) {
226+
t.Run("defaults to 30s when unset", func(t *testing.T) {
227+
cfg, err := loadConfigFromFlags(t)
228+
require.NoError(t, err)
229+
assert.Equal(t, 30*time.Second, cfg.WriteTimeout)
230+
})
231+
232+
cases := []struct {
233+
name string
234+
value string
235+
want time.Duration
236+
}{
237+
{"raised for slow aggregates", "120s", 120 * time.Second},
238+
{"zero disables the deadline", "0s", 0},
239+
}
240+
for _, tc := range cases {
241+
t.Run(tc.name+" (flag)", func(t *testing.T) {
242+
cfg, err := loadConfigFromFlags(t, "-write-timeout", tc.value)
243+
require.NoError(t, err)
244+
assert.Equal(t, tc.want, cfg.WriteTimeout)
245+
})
246+
t.Run(tc.name+" (pflag)", func(t *testing.T) {
247+
cfg, err := loadConfigFromPFlags(t, "--write-timeout", tc.value)
248+
require.NoError(t, err)
249+
assert.Equal(t, tc.want, cfg.WriteTimeout)
250+
})
251+
}
252+
}
253+
225254
func TestLoadMinimal_LoadsAgentBinaryConfig(t *testing.T) {
226255
f := newConfigFixture(t)
227256
f.WriteConfigText(t, `[agent.claude]

internal/server/middleware.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ func (s *Server) withTimeout(
3131
}
3232
}
3333

34+
// A non-positive write timeout disables the deadline, matching the typed
35+
// (Huma) API path. Passing 0 to http.TimeoutHandler would instead fire
36+
// immediately and 503 every request.
37+
if s.cfg.WriteTimeout <= 0 {
38+
return http.HandlerFunc(inner)
39+
}
40+
3441
handler := http.TimeoutHandler(
3542
inner, s.cfg.WriteTimeout, msg,
3643
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// A non-positive write timeout must disable the deadline instead of wrapping the
13+
// handler in http.TimeoutHandler with a 0 duration, which would fire immediately
14+
// and 503 every request.
15+
func TestWithTimeout_NonPositiveDisablesDeadline(t *testing.T) {
16+
t.Parallel()
17+
s := testServer(t, 0)
18+
19+
called := false
20+
h := s.withTimeout(func(w http.ResponseWriter, _ *http.Request) {
21+
called = true
22+
w.WriteHeader(http.StatusOK)
23+
})
24+
25+
req := httptest.NewRequest(http.MethodGet, "/api/v1/recall/entries", nil)
26+
w := httptest.NewRecorder()
27+
h.ServeHTTP(w, req)
28+
29+
require.True(t, called, "handler should run when the write timeout is disabled")
30+
assert.Equal(t, http.StatusOK, w.Code)
31+
}

0 commit comments

Comments
 (0)