diff --git a/cmd/keyoku-server/handlers.go b/cmd/keyoku-server/handlers.go index ab309be..9ede52c 100644 --- a/cmd/keyoku-server/handlers.go +++ b/cmd/keyoku-server/handlers.go @@ -4,7 +4,9 @@ package main import ( + "context" "encoding/json" + "errors" "log" "net/http" "strconv" @@ -30,6 +32,7 @@ func NewHandlers(k *keyoku.Keyoku, hub *SSEHub) *Handlers { type rememberRequest struct { EntityID string `json:"entity_id"` Content string `json:"content"` + TimeoutMs int `json:"timeout_ms,omitempty"` SessionID string `json:"session_id,omitempty"` AgentID string `json:"agent_id,omitempty"` Source string `json:"source,omitempty"` @@ -51,6 +54,7 @@ type rememberResponse struct { type searchRequest struct { EntityID string `json:"entity_id"` Query string `json:"query"` + TimeoutMs int `json:"timeout_ms,omitempty"` Limit int `json:"limit,omitempty"` Mode string `json:"mode,omitempty"` AgentID string `json:"agent_id,omitempty"` @@ -360,11 +364,52 @@ func writeInternalError(w http.ResponseWriter, err error) { writeInternalErrorWithContext(w, "", err) } -func writeInternalErrorWithContext(w http.ResponseWriter, context string, err error) { - if context == "" { +// isDeadlineExceeded returns true for wrapped context.DeadlineExceeded and also +// catches providers that return a non-wrapping string (older SDKs). Matches on +// the stable Go-standard message rather than arbitrary provider text. +func isDeadlineExceeded(err error) bool { + if errors.Is(err, context.DeadlineExceeded) { + return true + } + return strings.Contains(err.Error(), "context deadline exceeded") +} + +func isCanceled(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + return strings.Contains(err.Error(), "context canceled") +} + +func writeInternalErrorWithContext(w http.ResponseWriter, ctxLabel string, err error) { + // Caller-controlled budget exhaustion (timeout_ms or upstream deadline) maps + // to 504 with a stable code so clients can distinguish it from server faults. + if isDeadlineExceeded(err) { + if ctxLabel == "" { + log.Printf("INFO: request timed out: %v", err) + } else { + log.Printf("INFO [%s]: request timed out: %v", ctxLabel, err) + } + writeErrorCode(w, http.StatusGatewayTimeout, "request exceeded timeout budget", "request_timeout", true) + return + } + + // Client went away (disconnect / upstream cancellation). Nothing useful to + // send back; just log and return without touching the response writer so + // the connection isn't kept alive writing to a dead peer. + if isCanceled(err) { + if ctxLabel == "" { + log.Printf("INFO: request canceled by client: %v", err) + } else { + log.Printf("INFO [%s]: request canceled by client: %v", ctxLabel, err) + } + return + } + + if ctxLabel == "" { log.Printf("ERROR: %v", err) } else { - log.Printf("ERROR [%s]: %v", context, err) + log.Printf("ERROR [%s]: %v", ctxLabel, err) } errStr := err.Error() diff --git a/cmd/keyoku-server/handlers_errors_test.go b/cmd/keyoku-server/handlers_errors_test.go index eff3f76..2315c5a 100644 --- a/cmd/keyoku-server/handlers_errors_test.go +++ b/cmd/keyoku-server/handlers_errors_test.go @@ -4,8 +4,10 @@ package main import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -55,6 +57,62 @@ func TestWriteInternalErrorWithContext_GenericInternal(t *testing.T) { } } +func TestWriteInternalErrorWithContext_DeadlineExceededMapsTo504(t *testing.T) { + rr := httptest.NewRecorder() + + // Simulate the wrapping pattern used by engine_add.go and llm providers. + wrapped := fmt.Errorf("extraction failed: %w", context.DeadlineExceeded) + writeInternalErrorWithContext(rr, "remember", wrapped) + + if rr.Code != http.StatusGatewayTimeout { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusGatewayTimeout) + } + + var body apiErrorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Code != "request_timeout" { + t.Fatalf("code = %q, want request_timeout", body.Code) + } + if !body.Retryable { + t.Fatalf("retryable = false, want true") + } +} + +func TestWriteInternalErrorWithContext_DeadlineExceededStringFallback(t *testing.T) { + rr := httptest.NewRecorder() + + // Older provider SDKs may return the stdlib text without wrapping the sentinel. + writeInternalErrorWithContext(rr, "search", errors.New("provider call failed: context deadline exceeded")) + + if rr.Code != http.StatusGatewayTimeout { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusGatewayTimeout) + } + var body apiErrorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Code != "request_timeout" { + t.Fatalf("code = %q, want request_timeout", body.Code) + } +} + +func TestWriteInternalErrorWithContext_CanceledWritesNoResponse(t *testing.T) { + rr := httptest.NewRecorder() + + wrapped := fmt.Errorf("extraction failed: %w", context.Canceled) + writeInternalErrorWithContext(rr, "remember", wrapped) + + // Default recorder status is 200; no WriteHeader should have been called. + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want default 200 (no header written), got otherwise", rr.Code) + } + if rr.Body.Len() != 0 { + t.Fatalf("body = %q, want empty — client is gone, we should not write", rr.Body.String()) + } +} + func TestWriteInternalErrorWithContext_SimilarityPrefixWithoutHNSWIs500(t *testing.T) { rr := httptest.NewRecorder() diff --git a/cmd/keyoku-server/handlers_memory.go b/cmd/keyoku-server/handlers_memory.go index 40ef2a8..bb311ff 100644 --- a/cmd/keyoku-server/handlers_memory.go +++ b/cmd/keyoku-server/handlers_memory.go @@ -4,6 +4,7 @@ package main import ( + "context" "fmt" "net/http" "sort" @@ -14,6 +15,19 @@ import ( "github.com/keyoku-ai/keyoku-engine/storage" ) +func requestTimeout(timeoutMs int) (time.Duration, bool) { + if timeoutMs <= 0 { + return 0, false + } + if timeoutMs < 1000 { + timeoutMs = 1000 + } + if timeoutMs > 300000 { + timeoutMs = 300000 + } + return time.Duration(timeoutMs) * time.Millisecond, true +} + // HandleRemember extracts and stores memories from content. func (h *Handlers) HandleRemember(w http.ResponseWriter, r *http.Request) { var req rememberRequest @@ -66,7 +80,14 @@ func (h *Handlers) HandleRemember(w http.ResponseWriter, r *http.Request) { opts = append(opts, keyoku.WithCreatedAt(t)) } - result, err := h.k.Remember(r.Context(), req.EntityID, req.Content, opts...) + ctx := r.Context() + if timeout, ok := requestTimeout(req.TimeoutMs); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + result, err := h.k.Remember(ctx, req.EntityID, req.Content, opts...) if err != nil { writeInternalErrorWithContext(w, "remember", err) return @@ -115,7 +136,14 @@ func (h *Handlers) HandleSearch(w http.ResponseWriter, r *http.Request) { opts = append(opts, keyoku.WithSearchAgentID(req.AgentID)) } - results, err := h.k.Search(r.Context(), req.EntityID, req.Query, opts...) + ctx := r.Context() + if timeout, ok := requestTimeout(req.TimeoutMs); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + results, err := h.k.Search(ctx, req.EntityID, req.Query, opts...) if err != nil { writeInternalErrorWithContext(w, "search", err) return