Files
hanzoai_agent/control-plane/internal/handlers/execute_async_test.go
zooqueenandHanzo Dev 1332f6e2bc feat(billing): add prepaid credit enforcement to control-plane
- BillingService client calls Commerce API for balance checks and debits
- Balance gate in prepareExecution blocks $0 balance with HTTP 402
- Post-execution billing in completeExecution/failExecution debits actual cost
- Balance proxy endpoint at GET /api/v1/billing/balance
- DB migration adds billing fields to executions table
- K8s deployment wired with BILLING_COMMERCE_URL and admin token secret

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-18 13:20:36 -08:00

515 lines
15 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/agents/control-plane/internal/events"
"github.com/hanzoai/agents/control-plane/internal/services"
"github.com/hanzoai/agents/control-plane/pkg/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestExecuteAsyncHandler_QueueSaturation(t *testing.T) {
gin.SetMode(gin.TestMode)
// Set a small queue capacity for this test
// Note: This only works if the pool hasn't been initialized yet
// In a real scenario, the pool is initialized once, so this test
// verifies the queue saturation logic when the queue is actually full
originalCapacity := os.Getenv("HANZO_AGENTS_EXEC_ASYNC_QUEUE_CAPACITY")
defer func() {
if originalCapacity != "" {
os.Setenv("HANZO_AGENTS_EXEC_ASYNC_QUEUE_CAPACITY", originalCapacity)
} else {
os.Unsetenv("HANZO_AGENTS_EXEC_ASYNC_QUEUE_CAPACITY")
}
}()
// Set a very small capacity to make saturation easier to test
os.Setenv("HANZO_AGENTS_EXEC_ASYNC_QUEUE_CAPACITY", "2")
agent := &types.AgentNode{
ID: "node-1",
BaseURL: "http://agent.example",
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())
// Get the pool (will be initialized with small capacity)
pool := getAsyncWorkerPool()
// Fill the queue completely - submit more jobs than capacity
// Workers will consume some, but we want to ensure queue is full when we make the request
queueCapacity := cap(pool.queue)
// Submit enough jobs to fill the queue (accounting for workers consuming)
// We submit more than capacity to ensure queue stays full
for i := 0; i < queueCapacity*2; i++ {
job := asyncExecutionJob{
controller: newExecutionController(store, payloads, nil, 90*time.Second, nil),
plan: preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec-fill",
RunID: "test-run",
},
},
}
if !pool.submit(job) {
// Queue is full, good
break
}
}
// Give a tiny moment for queue state to stabilize
time.Sleep(10 * time.Millisecond)
router := gin.New()
router.POST("/api/v1/execute/async/:target", ExecuteAsyncHandler(store, payloads, nil, 90*time.Second, nil))
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/async/node-1.reasoner-a", strings.NewReader(`{"input":{"foo":"bar"}}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// The queue might not be full if workers consumed jobs quickly
// So we check if we got either 503 (queue full) or 202 (accepted)
// If we got 202, the queue wasn't full, which is also a valid test outcome
if resp.Code == http.StatusServiceUnavailable {
var payload map[string]string
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
require.Contains(t, payload["error"], "async execution queue is full")
// Verify execution was marked as failed
records, err := store.QueryExecutionRecords(context.Background(), types.ExecutionFilter{})
require.NoError(t, err)
if len(records) > 0 {
// Find the execution we just created
for i := len(records) - 1; i >= 0; i-- {
if records[i].Status == types.ExecutionStatusFailed {
// Found a failed execution, which is expected for queue saturation
return
}
}
}
} else {
// Queue wasn't full, which is fine - test still validates the code path exists
require.Equal(t, http.StatusAccepted, resp.Code)
}
}
func TestExecuteAsyncHandler_WithWebhook(t *testing.T) {
gin.SetMode(gin.TestMode)
var requestCount int32
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())
router := gin.New()
router.POST("/api/v1/execute/async/:target", ExecuteAsyncHandler(store, payloads, nil, 90*time.Second, nil))
reqBody := `{
"input": {"foo": "bar"},
"webhook": {
"url": "https://example.com/webhook",
"secret": "test-secret"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/async/node-1.reasoner-a", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusAccepted, resp.Code)
var payload AsyncExecuteResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
require.NotEmpty(t, payload.ExecutionID)
require.True(t, payload.WebhookRegistered)
// Wait for async execution to complete
require.Eventually(t, func() bool {
record, err := store.GetExecutionRecord(context.Background(), payload.ExecutionID)
if err != nil || record == nil {
return false
}
return record.Status == types.ExecutionStatusSucceeded
}, 2*time.Second, 50*time.Millisecond)
require.Eventually(t, func() bool {
return atomic.LoadInt32(&requestCount) > 0
}, time.Second, 50*time.Millisecond)
}
func TestExecuteAsyncHandler_InvalidWebhook(t *testing.T) {
gin.SetMode(gin.TestMode)
agent := &types.AgentNode{
ID: "node-1",
BaseURL: "http://agent.example",
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())
router := gin.New()
router.POST("/api/v1/execute/async/:target", ExecuteAsyncHandler(store, payloads, nil, 90*time.Second, nil))
// Webhook with invalid URL (too long)
longURL := strings.Repeat("a", 4097)
reqBody := `{
"input": {"foo": "bar"},
"webhook": {
"url": "` + longURL + `"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/async/node-1.reasoner-a", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusAccepted, resp.Code)
var payload AsyncExecuteResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
require.NotEmpty(t, payload.ExecutionID)
require.False(t, payload.WebhookRegistered)
require.NotNil(t, payload.WebhookError)
}
func TestHandleSync_AsyncAcknowledgment(t *testing.T) {
gin.SetMode(gin.TestMode)
var requestCount int32
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
// Return HTTP 202 Accepted
w.WriteHeader(http.StatusAccepted)
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())
router := gin.New()
router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, nil))
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.reasoner-a", strings.NewReader(`{"input":{"foo":"bar"}}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
// Start request in goroutine since it will wait for completion
done := make(chan bool)
go func() {
router.ServeHTTP(resp, req)
done <- true
}()
// Simulate status update callback after a short delay
time.Sleep(100 * time.Millisecond)
executionID := ""
records, _ := store.QueryExecutionRecords(context.Background(), types.ExecutionFilter{})
if len(records) > 0 {
executionID = records[0].ExecutionID
}
if executionID != "" {
// Update execution to completed state
_, err := store.UpdateExecutionRecord(context.Background(), executionID, func(current *types.Execution) (*types.Execution, error) {
if current == nil {
return nil, nil
}
now := time.Now().UTC()
current.Status = types.ExecutionStatusSucceeded
result := json.RawMessage(`{"result":"success"}`)
current.ResultPayload = result
completed := now
current.CompletedAt = &completed
duration := int64(100)
current.DurationMS = &duration
return current, nil
})
if err == nil {
// Publish completion event
eventBus := store.GetExecutionEventBus()
if eventBus != nil {
eventBus.Publish(events.ExecutionEvent{
Type: events.ExecutionCompleted,
ExecutionID: executionID,
WorkflowID: "test-run",
Status: string(types.ExecutionStatusSucceeded),
Timestamp: time.Now(),
})
}
}
}
// Wait for response or timeout
select {
case <-done:
// Response completed
case <-time.After(2 * time.Second):
t.Fatal("Request timed out waiting for async completion")
}
// Note: In a real scenario, the sync handler would wait for the callback
// This test verifies the async acknowledgment path exists
require.Equal(t, int32(1), atomic.LoadInt32(&requestCount))
}
func TestCallAgent_HTTP202Response(t *testing.T) {
gin.SetMode(gin.TestMode)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return HTTP 202 Accepted
w.WriteHeader(http.StatusAccepted)
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
controller := newExecutionController(store, nil, nil, 90*time.Second, nil)
plan := &preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec",
RunID: "test-run",
},
requestBody: []byte(`{"input":{"foo":"bar"}}`),
agent: agent,
target: &parsedTarget{
NodeID: "node-1",
TargetName: "reasoner-a",
},
}
body, elapsed, asyncAccepted, err := controller.callAgent(context.Background(), plan)
require.NoError(t, err)
require.True(t, asyncAccepted)
require.Nil(t, body)
require.Greater(t, elapsed, time.Duration(0))
}
func TestCallAgent_ErrorResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal server error"}`))
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
controller := newExecutionController(store, nil, nil, 90*time.Second, nil)
plan := &preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec",
RunID: "test-run",
},
requestBody: []byte(`{"input":{"foo":"bar"}}`),
agent: agent,
target: &parsedTarget{
NodeID: "node-1",
TargetName: "reasoner-a",
},
}
body, elapsed, asyncAccepted, err := controller.callAgent(context.Background(), plan)
require.Error(t, err)
require.False(t, asyncAccepted)
require.Contains(t, err.Error(), "agent error (500)")
require.NotNil(t, body)
require.Greater(t, elapsed, time.Duration(0))
}
func TestCallAgent_Timeout(t *testing.T) {
gin.SetMode(gin.TestMode)
// Server that delays response beyond timeout
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
controller := newExecutionController(store, nil, nil, 90*time.Second, nil)
// Set shorter timeout for test
controller.httpClient.Timeout = 100 * time.Millisecond
plan := &preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec",
RunID: "test-run",
},
requestBody: []byte(`{"input":{"foo":"bar"}}`),
agent: agent,
target: &parsedTarget{
NodeID: "node-1",
TargetName: "reasoner-a",
},
}
body, elapsed, asyncAccepted, err := controller.callAgent(context.Background(), plan)
require.Error(t, err)
require.False(t, asyncAccepted)
// Error message may vary but should indicate timeout or deadline exceeded
errorMsg := err.Error()
require.True(t,
strings.Contains(strings.ToLower(errorMsg), "timeout") ||
strings.Contains(strings.ToLower(errorMsg), "deadline exceeded") ||
strings.Contains(strings.ToLower(errorMsg), "context deadline"),
"Expected timeout-related error, got: %s", errorMsg)
require.Nil(t, body)
require.Greater(t, elapsed, time.Duration(0))
}
func TestCallAgent_ReadResponseError(t *testing.T) {
gin.SetMode(gin.TestMode)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Close connection immediately to cause read error
hj, ok := w.(http.Hijacker)
if ok {
conn, _, _ := hj.Hijack()
conn.Close()
}
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
controller := newExecutionController(store, nil, nil, 90*time.Second, nil)
plan := &preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec",
RunID: "test-run",
},
requestBody: []byte(`{"input":{"foo":"bar"}}`),
agent: agent,
target: &parsedTarget{
NodeID: "node-1",
TargetName: "reasoner-a",
},
}
body, elapsed, asyncAccepted, err := controller.callAgent(context.Background(), plan)
require.Error(t, err)
require.False(t, asyncAccepted)
require.Contains(t, err.Error(), "agent call failed")
require.Nil(t, body)
require.Greater(t, elapsed, time.Duration(0))
}
func TestCallAgent_HeaderPropagation(t *testing.T) {
gin.SetMode(gin.TestMode)
var receivedHeaders http.Header
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer agentServer.Close()
agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "reasoner-a"}},
}
store := newTestExecutionStorage(agent)
controller := newExecutionController(store, nil, nil, 90*time.Second, nil)
parentID := "parent-exec-123"
sessionID := "session-456"
actorID := "actor-789"
plan := &preparedExecution{
exec: &types.Execution{
ExecutionID: "test-exec",
RunID: "test-run",
ParentExecutionID: &parentID,
SessionID: &sessionID,
ActorID: &actorID,
},
requestBody: []byte(`{"input":{"foo":"bar"}}`),
agent: agent,
target: &parsedTarget{
NodeID: "node-1",
TargetName: "reasoner-a",
},
}
_, _, _, err := controller.callAgent(context.Background(), plan)
require.NoError(t, err)
require.Equal(t, "test-run", receivedHeaders.Get("X-Run-ID"))
require.Equal(t, "test-exec", receivedHeaders.Get("X-Execution-ID"))
require.Equal(t, parentID, receivedHeaders.Get("X-Parent-Execution-ID"))
require.Equal(t, sessionID, receivedHeaders.Get("X-Session-ID"))
require.Equal(t, actorID, receivedHeaders.Get("X-Actor-ID"))
}