Skip to content

Commit ff60321

Browse files
committed
feat: improve error handling for WebSocket message sending and enhance API documentation
1 parent c65ca51 commit ff60321

7 files changed

Lines changed: 222 additions & 42 deletions

File tree

docs/src/content/docs/reference/admin-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ Send a text or binary message to a specific active WebSocket connection.
10131013
| Field | Type | Description |
10141014
|-------|------|-------------|
10151015
| `type` | string | Message type: `"text"` (default) or `"binary"` |
1016-
| `data` | string | Message payload |
1016+
| `data` | string | Message payload. For `"text"`, a plain UTF-8 string. For `"binary"`, a **base64-encoded** string — the server decodes it before writing raw bytes to the WebSocket. |
10171017

10181018
**Response:**
10191019

pkg/admin/engineclient/types.go

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,32 +30,31 @@ type (
3030
ProtocolStatus = types.ProtocolStatus
3131
MockListResponse = types.MockListResponse
3232
// RequestFilter is kept for backward compatibility; use requestlog.Filter directly.
33-
RequestFilter = types.RequestLogFilter
34-
RequestLogEntry = types.RequestLogEntry
35-
RequestListResponse = types.RequestListResponse
36-
ErrorResponse = types.ErrorResponse
37-
ChaosConfig = types.ChaosConfig
38-
LatencyConfig = types.LatencyConfig
39-
ErrorRateConfig = types.ErrorRateConfig
40-
BandwidthConfig = types.BandwidthConfig
41-
ChaosRuleConfig = types.ChaosRuleConfig
42-
ChaosFaultConfig = types.ChaosFaultConfig
43-
ChaosStats = types.ChaosStats
44-
StatefulResource = types.StatefulResource
45-
StatefulItemsResponse = types.StatefulItemsResponse
46-
StateOverview = types.StateOverview
47-
ProtocolHandler = types.ProtocolHandler
48-
SSEConnection = types.SSEConnection
49-
SSEStats = types.SSEStats
50-
WebSocketConnection = types.WebSocketConnection
51-
WebSocketConnectionListResponse = types.WebSocketConnectionListResponse
52-
WebSocketStats = types.WebSocketStats
53-
CustomOperationInfo = types.CustomOperationInfo
54-
CustomOperationDetail = types.CustomOperationDetail
55-
CustomOperationStep = types.CustomOperationStep
56-
StatefulFaultStats = types.StatefulFaultStats
57-
CircuitBreakerStatus = types.CircuitBreakerStatus
58-
RetryAfterStatus = types.RetryAfterStatus
59-
ProgressiveDegradationStatus = types.ProgressiveDegradationStatus
60-
ResetStateResponse = types.ResetStateResponse
33+
RequestFilter = types.RequestLogFilter
34+
RequestLogEntry = types.RequestLogEntry
35+
RequestListResponse = types.RequestListResponse
36+
ErrorResponse = types.ErrorResponse
37+
ChaosConfig = types.ChaosConfig
38+
LatencyConfig = types.LatencyConfig
39+
ErrorRateConfig = types.ErrorRateConfig
40+
BandwidthConfig = types.BandwidthConfig
41+
ChaosRuleConfig = types.ChaosRuleConfig
42+
ChaosFaultConfig = types.ChaosFaultConfig
43+
ChaosStats = types.ChaosStats
44+
StatefulResource = types.StatefulResource
45+
StatefulItemsResponse = types.StatefulItemsResponse
46+
StateOverview = types.StateOverview
47+
ProtocolHandler = types.ProtocolHandler
48+
SSEConnection = types.SSEConnection
49+
SSEStats = types.SSEStats
50+
WebSocketConnection = types.WebSocketConnection
51+
WebSocketStats = types.WebSocketStats
52+
CustomOperationInfo = types.CustomOperationInfo
53+
CustomOperationDetail = types.CustomOperationDetail
54+
CustomOperationStep = types.CustomOperationStep
55+
StatefulFaultStats = types.StatefulFaultStats
56+
CircuitBreakerStatus = types.CircuitBreakerStatus
57+
RetryAfterStatus = types.RetryAfterStatus
58+
ProgressiveDegradationStatus = types.ProgressiveDegradationStatus
59+
ResetStateResponse = types.ResetStateResponse
6160
)

pkg/admin/sse_handlers.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,11 @@ func (a *API) handleCloseSSEConnection(w http.ResponseWriter, r *http.Request) {
150150
// handleGetSSEStats handles GET /sse/stats.
151151
func (a *API) handleGetSSEStats(w http.ResponseWriter, r *http.Request) {
152152
engine := a.localEngine.Load()
153-
provider := newSSEStatsProvider(engine)
154-
a.handleGetStats(w, r, provider)
153+
if engine == nil {
154+
writeJSON(w, http.StatusOK, sse.ConnectionStats{ConnectionsByMock: make(map[string]int)})
155+
return
156+
}
157+
a.handleGetStats(w, r, newSSEStatsProvider(engine))
155158
}
156159

157160
// handleListMockSSEConnections handles GET /mocks/{id}/sse/connections.

pkg/admin/stat_helper.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,11 @@ type statsProvider interface {
2020
}
2121

2222
// handleGetStats is a generic handler for retrieving statistics.
23-
// It eliminates code duplication between SSE and WebSocket stats handlers.
23+
// The caller must guard against a nil engine and handle the empty-stats
24+
// response before constructing the provider and invoking this function.
2425
func (a *API) handleGetStats(w http.ResponseWriter, r *http.Request, provider statsProvider) {
2526
ctx := r.Context()
2627

27-
engine := a.localEngine.Load()
28-
if engine == nil {
29-
writeJSON(w, http.StatusOK, provider.GetEmptyStats())
30-
return
31-
}
32-
3328
stats, err := provider.GetStats(ctx)
3429
if err != nil {
3530
a.logger().Error("failed to get stats", "error", err)

pkg/admin/websocket_handlers.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,11 @@ func (a *API) handleCloseWebSocketConnection(w http.ResponseWriter, r *http.Requ
134134
// handleGetWebSocketStats handles GET /websocket/stats.
135135
func (a *API) handleGetWebSocketStats(w http.ResponseWriter, r *http.Request) {
136136
engine := a.localEngine.Load()
137-
provider := newWSStatsProvider(engine)
138-
a.handleGetStats(w, r, provider)
137+
if engine == nil {
138+
writeJSON(w, http.StatusOK, engineclient.WebSocketStats{ConnectionsByMock: make(map[string]int)})
139+
return
140+
}
141+
a.handleGetStats(w, r, newWSStatsProvider(engine))
139142
}
140143

141144
func mapWebSocketEngineError(err error, log *slog.Logger, operation string) (int, string, string) {

pkg/engine/api/handlers.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/getmockd/mockd/pkg/httputil"
1616
"github.com/getmockd/mockd/pkg/requestlog"
1717
"github.com/getmockd/mockd/pkg/stateful"
18+
"github.com/getmockd/mockd/pkg/websocket"
1819
)
1920

2021
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -856,7 +857,11 @@ func (s *Server) handleSendToWebSocketConnection(w http.ResponseWriter, r *http.
856857
}
857858

858859
if err := s.engine.SendToWebSocketConnection(id, req.Type, payload); err != nil {
859-
writeError(w, http.StatusNotFound, "not_found", "WebSocket connection not found")
860+
if errors.Is(err, websocket.ErrConnectionNotFound) || errors.Is(err, websocket.ErrConnectionClosed) {
861+
writeError(w, http.StatusNotFound, "not_found", "WebSocket connection not found or closed")
862+
} else {
863+
writeError(w, http.StatusInternalServerError, "send_error", "Failed to send message to WebSocket connection")
864+
}
860865
return
861866
}
862867

pkg/engine/api/handlers_test.go

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/getmockd/mockd/pkg/mock"
1818
"github.com/getmockd/mockd/pkg/requestlog"
1919
"github.com/getmockd/mockd/pkg/stateful"
20+
"github.com/getmockd/mockd/pkg/websocket"
2021
)
2122

2223
// mockEngine is a test double for EngineController.
@@ -49,13 +50,17 @@ type mockEngine struct {
4950
resetStateErr error
5051
listStatefulItemsErr error
5152
getStatefulItemErr error
53+
// wsSendErr allows injecting a specific error for a connection ID in
54+
// SendToWebSocketConnection. Keyed by connection ID.
55+
wsSendErr map[string]error
5256
}
5357

5458
func newMockEngine() *mockEngine {
5559
return &mockEngine{
5660
mocks: make(map[string]*config.MockConfiguration),
5761
requestLogs: make(map[string]*requestlog.Entry),
5862
customOps: make(map[string]*CustomOperationDetail),
63+
wsSendErr: make(map[string]error),
5964
running: true,
6065
uptime: 100,
6166
protocols: map[string]ProtocolStatusInfo{
@@ -360,12 +365,16 @@ func (m *mockEngine) GetWebSocketStats() *WebSocketStats {
360365
}
361366

362367
func (m *mockEngine) SendToWebSocketConnection(id string, msgType string, data []byte) error {
368+
// Allow per-connection error injection for testing specific error paths.
369+
if err, ok := m.wsSendErr[id]; ok {
370+
return err
371+
}
363372
for _, c := range m.wsConnections {
364373
if c.ID == id {
365374
return nil
366375
}
367376
}
368-
return errors.New("connection not found")
377+
return websocket.ErrConnectionNotFound
369378
}
370379

371380
func (m *mockEngine) GetConfig() *ConfigResponse {
@@ -2406,3 +2415,169 @@ func TestHandleGetWebSocketStats(t *testing.T) {
24062415
assert.Equal(t, 3, stats.ConnectionsByMock["mock-1"])
24072416
})
24082417
}
2418+
2419+
// TestHandleSendToWebSocketConnection covers every branch of the send handler,
2420+
// including the new 404/500 distinction introduced in the review fix.
2421+
func TestHandleSendToWebSocketConnection(t *testing.T) {
2422+
t.Run("returns 400 when connection id is missing", func(t *testing.T) {
2423+
engine := newMockEngine()
2424+
server := newTestServer(engine)
2425+
2426+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections//send",
2427+
strings.NewReader(`{"type":"text","data":"hello"}`))
2428+
// PathValue "id" intentionally not set → empty string
2429+
rec := httptest.NewRecorder()
2430+
2431+
server.handleSendToWebSocketConnection(rec, req)
2432+
2433+
assert.Equal(t, http.StatusBadRequest, rec.Code)
2434+
var resp map[string]interface{}
2435+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2436+
assert.Equal(t, "missing_id", resp["error"])
2437+
})
2438+
2439+
t.Run("returns 400 for invalid JSON body", func(t *testing.T) {
2440+
engine := newMockEngine()
2441+
server := newTestServer(engine)
2442+
2443+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2444+
strings.NewReader(`{invalid`))
2445+
req.SetPathValue("id", "ws-1")
2446+
rec := httptest.NewRecorder()
2447+
2448+
server.handleSendToWebSocketConnection(rec, req)
2449+
2450+
assert.Equal(t, http.StatusBadRequest, rec.Code)
2451+
})
2452+
2453+
t.Run("returns 400 for invalid base64 binary payload", func(t *testing.T) {
2454+
engine := newMockEngine()
2455+
server := newTestServer(engine)
2456+
engine.wsConnections = []*WebSocketConnection{{ID: "ws-1"}}
2457+
2458+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2459+
strings.NewReader(`{"type":"binary","data":"not-valid-base64!!!"}`))
2460+
req.SetPathValue("id", "ws-1")
2461+
rec := httptest.NewRecorder()
2462+
2463+
server.handleSendToWebSocketConnection(rec, req)
2464+
2465+
assert.Equal(t, http.StatusBadRequest, rec.Code)
2466+
var resp map[string]interface{}
2467+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2468+
assert.Equal(t, "invalid_base64", resp["error"])
2469+
})
2470+
2471+
t.Run("returns 200 for text message to existing connection", func(t *testing.T) {
2472+
engine := newMockEngine()
2473+
server := newTestServer(engine)
2474+
engine.wsConnections = []*WebSocketConnection{{ID: "ws-1"}}
2475+
2476+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2477+
strings.NewReader(`{"type":"text","data":"hello"}`))
2478+
req.SetPathValue("id", "ws-1")
2479+
rec := httptest.NewRecorder()
2480+
2481+
server.handleSendToWebSocketConnection(rec, req)
2482+
2483+
assert.Equal(t, http.StatusOK, rec.Code)
2484+
var resp map[string]interface{}
2485+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2486+
assert.Equal(t, "Message sent", resp["message"])
2487+
assert.Equal(t, "ws-1", resp["connection"])
2488+
assert.Equal(t, "text", resp["type"])
2489+
})
2490+
2491+
t.Run("returns 200 for valid base64 binary message", func(t *testing.T) {
2492+
engine := newMockEngine()
2493+
server := newTestServer(engine)
2494+
engine.wsConnections = []*WebSocketConnection{{ID: "ws-1"}}
2495+
2496+
// base64("hello") = "aGVsbG8="
2497+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2498+
strings.NewReader(`{"type":"binary","data":"aGVsbG8="}`))
2499+
req.SetPathValue("id", "ws-1")
2500+
rec := httptest.NewRecorder()
2501+
2502+
server.handleSendToWebSocketConnection(rec, req)
2503+
2504+
assert.Equal(t, http.StatusOK, rec.Code)
2505+
var resp map[string]interface{}
2506+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2507+
assert.Equal(t, "binary", resp["type"])
2508+
})
2509+
2510+
t.Run("defaults type to text when omitted", func(t *testing.T) {
2511+
engine := newMockEngine()
2512+
server := newTestServer(engine)
2513+
engine.wsConnections = []*WebSocketConnection{{ID: "ws-1"}}
2514+
2515+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2516+
strings.NewReader(`{"data":"hello"}`))
2517+
req.SetPathValue("id", "ws-1")
2518+
rec := httptest.NewRecorder()
2519+
2520+
server.handleSendToWebSocketConnection(rec, req)
2521+
2522+
assert.Equal(t, http.StatusOK, rec.Code)
2523+
var resp map[string]interface{}
2524+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2525+
assert.Equal(t, "text", resp["type"])
2526+
})
2527+
2528+
t.Run("returns 404 when connection is not found", func(t *testing.T) {
2529+
engine := newMockEngine()
2530+
server := newTestServer(engine)
2531+
// No connections registered → ErrConnectionNotFound
2532+
2533+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/missing/send",
2534+
strings.NewReader(`{"type":"text","data":"hello"}`))
2535+
req.SetPathValue("id", "missing")
2536+
rec := httptest.NewRecorder()
2537+
2538+
server.handleSendToWebSocketConnection(rec, req)
2539+
2540+
assert.Equal(t, http.StatusNotFound, rec.Code)
2541+
var resp map[string]interface{}
2542+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2543+
assert.Equal(t, "not_found", resp["error"])
2544+
})
2545+
2546+
t.Run("returns 404 when connection is already closed", func(t *testing.T) {
2547+
engine := newMockEngine()
2548+
server := newTestServer(engine)
2549+
// Inject ErrConnectionClosed for ws-1 (connection exists but is closed)
2550+
engine.wsSendErr["ws-1"] = websocket.ErrConnectionClosed
2551+
2552+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2553+
strings.NewReader(`{"type":"text","data":"hello"}`))
2554+
req.SetPathValue("id", "ws-1")
2555+
rec := httptest.NewRecorder()
2556+
2557+
server.handleSendToWebSocketConnection(rec, req)
2558+
2559+
assert.Equal(t, http.StatusNotFound, rec.Code)
2560+
var resp map[string]interface{}
2561+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2562+
assert.Equal(t, "not_found", resp["error"])
2563+
})
2564+
2565+
t.Run("returns 500 for unexpected write error", func(t *testing.T) {
2566+
engine := newMockEngine()
2567+
server := newTestServer(engine)
2568+
// Inject a generic I/O error (e.g., broken pipe)
2569+
engine.wsSendErr["ws-1"] = errors.New("write: broken pipe")
2570+
2571+
req := httptest.NewRequest(http.MethodPost, "/websocket/connections/ws-1/send",
2572+
strings.NewReader(`{"type":"text","data":"hello"}`))
2573+
req.SetPathValue("id", "ws-1")
2574+
rec := httptest.NewRecorder()
2575+
2576+
server.handleSendToWebSocketConnection(rec, req)
2577+
2578+
assert.Equal(t, http.StatusInternalServerError, rec.Code)
2579+
var resp map[string]interface{}
2580+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
2581+
assert.Equal(t, "send_error", resp["error"])
2582+
})
2583+
}

0 commit comments

Comments
 (0)