Skip to content

Commit a16b389

Browse files
authored
Improve panic error recovery (#176)
* - improve catching and handling panic errors * - split validateRows into two functions
1 parent 264a352 commit a16b389

File tree

3 files changed

+162
-3
lines changed

3 files changed

+162
-3
lines changed

datasource.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,22 @@ func (ds *SQLDatasource) QueryData(ctx context.Context, req *backend.QueryDataRe
128128
stack := string(debug.Stack())
129129
errorMsg := fmt.Sprintf("SQL datasource query execution panic: %v", r)
130130

131+
// Log panic without sensitive query data
131132
backend.Logger.Error(errorMsg,
132133
"panic", r,
133134
"refID", query.RefID,
134-
"stack", stack)
135-
136-
response.Set(query.RefID, backend.ErrorResponseWithErrorSource(backend.PluginError(errors.New(errorMsg))))
135+
"queryType", query.QueryType,
136+
"maxDataPoints", query.MaxDataPoints,
137+
"interval", query.Interval)
138+
139+
// Log stack trace separately at debug level to avoid exposing in production
140+
backend.Logger.Debug("Panic stack trace", "stack", stack)
141+
142+
response.Set(query.RefID, backend.DataResponse{
143+
Frames: nil,
144+
Error: backend.PluginError(errors.New(errorMsg)),
145+
ErrorSource: backend.ErrorSourcePlugin,
146+
})
137147
}
138148
}()
139149

datasource_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package sqlds_test
22

33
import (
44
"context"
5+
"database/sql"
6+
"database/sql/driver"
57
"encoding/json"
68
"errors"
79
"fmt"
@@ -10,6 +12,7 @@ import (
1012
"github.com/grafana/grafana-plugin-sdk-go/backend"
1113
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
1214
"github.com/grafana/sqlds/v4"
15+
"github.com/grafana/sqlds/v4/mock"
1316
"github.com/grafana/sqlds/v4/test"
1417
"github.com/stretchr/testify/assert"
1518
)
@@ -223,6 +226,68 @@ func Test_query_panic_recovery(t *testing.T) {
223226
assert.Nil(t, res.Frames)
224227
}
225228

229+
func Test_query_panic_in_rows_validation(t *testing.T) {
230+
cfg := `{ "timeout": 0, "retries": 0, "retryOn": [] }`
231+
opts := test.DriverOpts{}
232+
233+
// Set up a driver that returns rows which will cause a panic when accessing columns
234+
// This will be caught by our validateRows function
235+
customQueryFunc := func(args []driver.Value) (driver.Rows, error) {
236+
// Return a panicking rows implementation that will cause a panic when columns are accessed
237+
return &panickingRows{}, nil
238+
}
239+
240+
// Create a custom driver with a wrapper for the query method
241+
driverName := "panic-rows-test"
242+
243+
// Create and register the handler
244+
handler := test.NewDriverHandler(test.Data{}, opts)
245+
246+
// Create a custom handler that overrides the Query method
247+
customHandler := &panickingDBHandler{
248+
SqlHandler: handler,
249+
customQueryFunc: customQueryFunc,
250+
}
251+
252+
mock.RegisterDriver(driverName, customHandler)
253+
254+
// Create datasource with the custom driver
255+
testDS := &testDriver{driverName: driverName}
256+
ds := sqlds.NewDatasource(testDS)
257+
258+
// Set up the query request
259+
req, settings := setupQueryRequest("panic-rows-validation", cfg)
260+
_, err := ds.NewDatasource(context.Background(), settings)
261+
assert.Nil(t, err)
262+
263+
// Execute the query
264+
data, err := ds.QueryData(context.Background(), req)
265+
266+
// Verify that the panic was caught and converted to an error
267+
assert.Nil(t, err)
268+
assert.NotNil(t, data.Responses)
269+
270+
res := data.Responses["foo"]
271+
assert.NotNil(t, res.Error)
272+
assert.Contains(t, res.Error.Error(), "failed to validate rows")
273+
assert.NotNil(t, res.Frames) // Error frame is returned, not nil
274+
}
275+
276+
// panickingRows is a custom rows implementation that panics when columns are accessed
277+
type panickingRows struct{}
278+
279+
func (r *panickingRows) Columns() []string {
280+
panic("panic in Columns method")
281+
}
282+
283+
func (r *panickingRows) Close() error {
284+
return nil
285+
}
286+
287+
func (r *panickingRows) Next(dest []driver.Value) error {
288+
return nil
289+
}
290+
226291
func queryRequest(t *testing.T, name string, opts test.DriverOpts, cfg string, marcos sqlds.Macros) (*backend.QueryDataRequest, *test.SqlHandler, *sqlds.SQLDatasource) {
227292
driver, handler := test.NewDriver(name, test.Data{}, nil, opts, marcos)
228293
ds := sqlds.NewDatasource(driver)
@@ -269,3 +334,50 @@ func setupHealthRequest(id string, cfg string) (backend.CheckHealthRequest, back
269334
}
270335
return req, settings
271336
}
337+
338+
// testDriver implements sqlds.Driver interface for testing
339+
type testDriver struct {
340+
driverName string
341+
}
342+
343+
func (d *testDriver) Connect(ctx context.Context, cfg backend.DataSourceInstanceSettings, msg json.RawMessage) (*sql.DB, error) {
344+
return sql.Open(d.driverName, "")
345+
}
346+
347+
func (d *testDriver) Settings(ctx context.Context, config backend.DataSourceInstanceSettings) sqlds.DriverSettings {
348+
settings, _ := test.LoadSettings(ctx, config)
349+
return settings
350+
}
351+
352+
func (d *testDriver) Macros() sqlds.Macros {
353+
return nil
354+
}
355+
356+
func (d *testDriver) Converters() []sqlutil.Converter {
357+
return nil
358+
}
359+
360+
// panickingDBHandler implements mock.DBHandler and causes panics when querying
361+
type panickingDBHandler struct {
362+
test.SqlHandler
363+
customQueryFunc func(args []driver.Value) (driver.Rows, error)
364+
}
365+
366+
func (h *panickingDBHandler) Query(args []driver.Value) (driver.Rows, error) {
367+
if h.customQueryFunc != nil {
368+
return h.customQueryFunc(args)
369+
}
370+
return h.SqlHandler.Query(args)
371+
}
372+
373+
func (h *panickingDBHandler) Ping(ctx context.Context) error {
374+
return nil
375+
}
376+
377+
func (h *panickingDBHandler) Columns() []string {
378+
return []string{"test_column"}
379+
}
380+
381+
func (h *panickingDBHandler) Next(dest []driver.Value) error {
382+
return errors.New("no more rows")
383+
}

query.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"net/http"
1010
"time"
1111

12+
"runtime/debug"
13+
1214
"github.com/grafana/dataplane/sdata/timeseries"
1315
"github.com/grafana/grafana-plugin-sdk-go/backend"
1416
"github.com/grafana/grafana-plugin-sdk-go/data"
@@ -123,7 +125,14 @@ func (q *DBQuery) Run(ctx context.Context, query *Query, args ...interface{}) (d
123125
return res, nil
124126
}
125127

128+
// getFrames converts rows to dataframes
126129
func getFrames(rows *sql.Rows, limit int64, converters []sqlutil.Converter, fillMode *data.FillMissing, query *Query) (data.Frames, error) {
130+
// Validate rows before processing to prevent panics
131+
if err := validateRows(rows); err != nil {
132+
backend.Logger.Error("Invalid SQL rows", "error", err.Error())
133+
return nil, err
134+
}
135+
127136
frame, err := sqlutil.FrameFromRows(rows, limit, converters...)
128137
if err != nil {
129138
return nil, err
@@ -185,6 +194,34 @@ func getFrames(rows *sql.Rows, limit int64, converters []sqlutil.Converter, fill
185194
return data.Frames{frame}, nil
186195
}
187196

197+
// accessColumns checks whether we can access rows.Columns, checking
198+
// for error or panic. In the case of panic, logs the stack trace at debug level
199+
// for security
200+
func accessColumns(rows *sql.Rows) (columnErr error) {
201+
defer func() {
202+
if r := recover(); r != nil {
203+
columnErr = fmt.Errorf("panic accessing columns: %v", r)
204+
stack := string(debug.Stack())
205+
backend.Logger.Debug("accessColumns panic stack trace", "stack", stack)
206+
}
207+
}()
208+
_, columnErr = rows.Columns()
209+
return columnErr
210+
}
211+
212+
// validateRows performs safety checks on SQL rows to prevent panics
213+
func validateRows(rows *sql.Rows) error {
214+
if rows == nil {
215+
return fmt.Errorf("rows is nil")
216+
}
217+
218+
err := accessColumns(rows)
219+
if err != nil {
220+
return fmt.Errorf("failed to validate rows: %w", err)
221+
}
222+
return nil
223+
}
224+
188225
// fixFrameForLongToMulti edits the passed in frame so that it's first time field isn't nullable and has the correct meta
189226
func fixFrameForLongToMulti(frame *data.Frame) error {
190227
if frame == nil {

0 commit comments

Comments
 (0)