mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
- 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]>
515 lines
15 KiB
Go
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"))
|
|
}
|