Skip to content

Commit 956e687

Browse files
committed
DTIN-6364: Rate limit by LRQ session rather than http request
1 parent 1ebe805 commit 956e687

File tree

5 files changed

+127
-9
lines changed

5 files changed

+127
-9
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 3.1.6
4+
5+
- Rate limit DataSet LRQ sessions rather than HTTP requests
6+
- Prevents LRQ sessions from being terminated if a LRQ ping is throttled
7+
38
## 3.1.5
49

510
- Updated grafana-plugin-sdk-go to 0.250.0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sentinelone-dataset-datasource",
3-
"version": "3.1.5",
3+
"version": "3.1.6",
44
"description": "Scalyr Observability Platform",
55
"scripts": {
66
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",

pkg/plugin/client.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func NewDataSetClient(dataSetUrl string, apiKey string) DataSetClient {
4646

4747
// TODO Are there alternate approaches to implementing rate limits via the Grafana SDK?
4848
// Consult with Grafana support about this, potentially there's a simplier option.
49-
rateLimiter := rate.NewLimiter(100*rate.Every(1*time.Minute), 100) // 100 requests / minute
49+
rateLimiter := rate.NewLimiter(100*rate.Every(1*time.Minute), 100) // 100 "requests" / minute
5050

5151
return &dataSetClient{
5252
dataSetUrl: dataSetUrl,
@@ -57,11 +57,16 @@ func NewDataSetClient(dataSetUrl string, apiKey string) DataSetClient {
5757
}
5858

5959
func (d *dataSetClient) newRequest(method, url string, body io.Reader) (*http.Request, error) {
60-
const VERSION = "3.1.5"
61-
62-
if err := d.rateLimiter.Wait(context.Background()); err != nil {
63-
log.DefaultLogger.Error("error applying rate limiter", "err", err)
64-
return nil, err
60+
const VERSION = "3.1.6"
61+
62+
// Apply the rate limiter to the initial POST request of the LRQ api session.
63+
// This ensures that later LRQ api "pings" (ie GET requests) are not rate-limited;
64+
// timely pings are necessary to prevent the LRQ session from being terminated.
65+
if method == http.MethodPost {
66+
if err := d.rateLimiter.Wait(context.Background()); err != nil {
67+
log.DefaultLogger.Error("error applying rate limiter", "err", err)
68+
return nil, err
69+
}
6570
}
6671

6772
request, err := http.NewRequest(method, url, body)

pkg/plugin/client_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package plugin
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"sort"
9+
"strconv"
10+
"sync"
11+
"testing"
12+
"time"
13+
14+
"golang.org/x/time/rate"
15+
)
16+
17+
func TestClientRateLimiter(t *testing.T) {
18+
mockedServerState := struct {
19+
requestTimesByMethod map[string][]time.Time
20+
nextSessionId int
21+
mutex sync.Mutex
22+
}{
23+
requestTimesByMethod: make(map[string][]time.Time),
24+
}
25+
26+
mockedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
const totalSteps = 2
28+
29+
mockedServerState.mutex.Lock()
30+
defer mockedServerState.mutex.Unlock()
31+
32+
mockedServerState.requestTimesByMethod[r.Method] = append(mockedServerState.requestTimesByMethod[r.Method], time.Now())
33+
t.Logf("request %s %s %s", time.Now().Format("15:04:05"), r.Method, r.URL.String())
34+
35+
if r.Method == http.MethodPost {
36+
sessionId := mockedServerState.nextSessionId
37+
mockedServerState.nextSessionId++
38+
w.WriteHeader(http.StatusOK)
39+
w.Write([]byte(fmt.Sprintf("{\"id\":\"%d\",\"stepsCompleted\":0,\"totalSteps\":%d}", sessionId, totalSteps)))
40+
} else if r.Method == http.MethodGet {
41+
sessionId := r.URL.Path
42+
lastStepSeen, err := strconv.Atoi(r.URL.Query().Get("lastStepSeen"))
43+
if err != nil {
44+
t.Fatalf("failed to parse path %s: %v", r.URL.String(), err)
45+
}
46+
w.WriteHeader(http.StatusOK)
47+
w.Write([]byte(fmt.Sprintf("{\"id\":\"%s\",\"stepsCompleted\":%d,\"totalSteps\":%d}", sessionId, lastStepSeen+1, totalSteps)))
48+
} else if r.Method == http.MethodDelete {
49+
w.WriteHeader(http.StatusOK)
50+
}
51+
}))
52+
defer mockedServer.Close()
53+
54+
datasetClient := &dataSetClient{
55+
dataSetUrl: mockedServer.URL,
56+
apiKey: "<api-key>",
57+
netClient: &http.Client{},
58+
rateLimiter: rate.NewLimiter(1*rate.Every(1*time.Second), 1),
59+
}
60+
61+
request := LRQRequest{
62+
QueryType: PQ,
63+
StartTime: time.Now().Add(-4 * time.Hour).Unix(),
64+
EndTime: time.Now().Unix(),
65+
Pq: &PQOptions{
66+
Query: "message contains 'error'\n| columns timestamp,severity,message",
67+
ResultType: TABLE,
68+
},
69+
}
70+
71+
var waitGroup sync.WaitGroup
72+
for i := 0; i < 3; i++ {
73+
waitGroup.Add(1)
74+
go func() {
75+
defer waitGroup.Done()
76+
datasetClient.DoLRQRequest(context.Background(), request)
77+
}()
78+
}
79+
waitGroup.Wait()
80+
81+
// Find the minimum time difference between consecutive elements.
82+
// The elements are expected to be already sorted ascendingly.
83+
minTimeDiff := func(times []time.Time) time.Duration {
84+
rv := times[1].Sub(times[0])
85+
for i := 2; i < len(times); i++ {
86+
if diff := times[i].Sub(times[i-1]); diff < rv {
87+
rv = diff
88+
}
89+
}
90+
return rv
91+
}
92+
93+
// The rate limiter is set to only allow one session (POST request) per second.
94+
// Verify that the minimum amount of time between consecutive POST requests is at least one second.
95+
if diff := minTimeDiff(mockedServerState.requestTimesByMethod[http.MethodPost]); diff.Round(time.Second) < time.Second {
96+
t.Errorf("expected >= 1s, actual = %v", diff)
97+
}
98+
99+
// There are no restrictions on GET or DELETE requests.
100+
// Verify that the minimum amount of time between non-POST requests is less than 500 milliseconds.
101+
var requestTimes []time.Time
102+
requestTimes = append(requestTimes, mockedServerState.requestTimesByMethod[http.MethodGet]...)
103+
requestTimes = append(requestTimes, mockedServerState.requestTimesByMethod[http.MethodDelete]...)
104+
sort.Slice(requestTimes, func(i, j int) bool { return requestTimes[i].Before(requestTimes[j]) })
105+
if diff := minTimeDiff(requestTimes); diff.Round(time.Millisecond) > 500 * time.Millisecond {
106+
t.Errorf("expected < 500ms, actual = %v", diff)
107+
}
108+
}

src/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
"path": "img/DatasetConfig.png"
4444
}
4545
],
46-
"version": "3.1.5",
47-
"updated": "2025-07-24"
46+
"version": "3.1.6",
47+
"updated": "2025-09-19"
4848
},
4949
"dependencies": {
5050
"grafanaDependency": ">=8.2.0",

0 commit comments

Comments
 (0)