mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
4cfc77994d
When http.send reaches out to the server if an entry is not found in the cache or a stale entry needs to revalidated, a network error while calling the server will result in http.send recording the error in the cache. It may sometimes be useful to retry the request in case of intermittent failures etc. This change adds a new option to the http.send input object which allows policy authors to specify a retry count for executing a HTTP request. Retries are performed with an exponential backoff delay. Signed-off-by: Ashutosh Narkar <anarkar4387@gmail.com>
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
// Copyright 2020 The OPA Authors. All rights reserved.
|
|
// Use of this source code is governed by an Apache2
|
|
// license that can be found in the LICENSE file.
|
|
|
|
//go:build slow
|
|
// +build slow
|
|
|
|
package topdown
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/open-policy-agent/opa/ast"
|
|
)
|
|
|
|
func TestHTTPSendTimeout(t *testing.T) {
|
|
|
|
// Each test can tweak the response delay, default is 0 with no delay
|
|
var responseDelay time.Duration
|
|
|
|
tsMtx := sync.Mutex{}
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tsMtx.Lock()
|
|
defer tsMtx.Unlock()
|
|
time.Sleep(responseDelay)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`hello`))
|
|
}))
|
|
// Note: We don't Close() the test server as it will block waiting for the
|
|
// timed out clients connections to shut down gracefully (they wont).
|
|
// We don't need to clean it up nicely for the unit test.
|
|
|
|
tests := []struct {
|
|
note string
|
|
rule string
|
|
input string
|
|
defaultTimeout time.Duration
|
|
evalTimeout time.Duration
|
|
serverDelay time.Duration
|
|
expected interface{}
|
|
}{
|
|
{
|
|
note: "no timeout",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%" }, resp); x := remove_headers(resp) }`,
|
|
expected: `{"body": null, "raw_body": "hello", "status": "200 OK", "status_code": 200}`,
|
|
},
|
|
{
|
|
note: "default timeout",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%" }, x) }`,
|
|
evalTimeout: 1 * time.Minute,
|
|
serverDelay: 5 * time.Second,
|
|
defaultTimeout: 500 * time.Millisecond,
|
|
expected: &Error{Code: BuiltinErr, Message: "request timed out"},
|
|
},
|
|
{
|
|
note: "eval timeout",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%" }, x) }`,
|
|
evalTimeout: 500 * time.Millisecond,
|
|
serverDelay: 5 * time.Second,
|
|
defaultTimeout: 1 * time.Minute,
|
|
expected: &Error{Code: CancelErr, Message: "timed out (context deadline exceeded)"},
|
|
},
|
|
{
|
|
note: "param timeout less than default",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%", "timeout": "500ms"}, x) }`,
|
|
evalTimeout: 1 * time.Minute,
|
|
serverDelay: 5 * time.Second,
|
|
defaultTimeout: 1 * time.Minute,
|
|
expected: &Error{Code: BuiltinErr, Message: "request timed out"},
|
|
},
|
|
{
|
|
note: "param timeout greater than default",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%", "timeout": "500ms"}, x) }`,
|
|
evalTimeout: 1 * time.Minute,
|
|
serverDelay: 5 * time.Second,
|
|
defaultTimeout: 1 * time.Millisecond,
|
|
expected: &Error{Code: BuiltinErr, Message: "request timed out"},
|
|
},
|
|
{
|
|
note: "eval timeout less than param",
|
|
rule: `p = x { http.send({"method": "get", "url": "%URL%", "timeout": "1m" }, x) }`,
|
|
evalTimeout: 500 * time.Millisecond,
|
|
serverDelay: 5 * time.Second,
|
|
defaultTimeout: 1 * time.Minute,
|
|
expected: &Error{Code: CancelErr, Message: "timed out (context deadline exceeded)"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tsMtx.Lock()
|
|
responseDelay = tc.serverDelay
|
|
tsMtx.Unlock()
|
|
|
|
ctx := context.Background()
|
|
if tc.evalTimeout > 0 {
|
|
ctx, _ = context.WithTimeout(ctx, tc.evalTimeout)
|
|
}
|
|
|
|
// TODO(patrick-east): Remove this along with the environment variable so that the "default" can't change
|
|
originalDefaultTimeout := defaultHTTPRequestTimeout
|
|
if tc.defaultTimeout > 0 {
|
|
defaultHTTPRequestTimeout = tc.defaultTimeout
|
|
}
|
|
|
|
rule := strings.ReplaceAll(tc.rule, "%URL%", ts.URL)
|
|
if e, ok := tc.expected.(*Error); ok {
|
|
e.Message = strings.ReplaceAll(e.Message, "%URL%", ts.URL)
|
|
}
|
|
|
|
runTopDownTestCaseWithContext(ctx, t, map[string]interface{}{}, tc.note, append(httpSendHelperRules, rule), nil, tc.input, tc.expected)
|
|
|
|
// Put back the default (may not have changed)
|
|
defaultHTTPRequestTimeout = originalDefaultTimeout
|
|
}
|
|
}
|
|
|
|
func TestHTTPSendRetryRequest(t *testing.T) {
|
|
|
|
tests := []struct {
|
|
note string
|
|
query string
|
|
response string
|
|
evalTimeout time.Duration
|
|
cancel Cancel
|
|
wantErr bool
|
|
err error
|
|
}{
|
|
{
|
|
note: "success",
|
|
query: `http.send({"method": "get", "url": "%URL%", "force_json_decode": true, "max_retry_attempts": 100, "timeout": "500ms"}, x)`,
|
|
response: `{"x": 1}`,
|
|
},
|
|
{
|
|
note: "eval timeout",
|
|
query: `http.send({"method": "get", "url": "%URL%", "force_json_decode": true, "max_retry_attempts": 100, "timeout": "500ms"}, x)`,
|
|
evalTimeout: 2 * time.Second,
|
|
wantErr: true,
|
|
err: fmt.Errorf("eval_cancel_error: http.send: timed out (context deadline exceeded)"),
|
|
},
|
|
{
|
|
note: "cancel query",
|
|
query: `http.send({"method": "get", "url": "%URL%", "force_json_decode": true, "max_retry_attempts": 100, "timeout": "500ms"}, x)`,
|
|
cancel: NewCancel(),
|
|
wantErr: true,
|
|
err: fmt.Errorf("eval_cancel_error: caller cancelled query execution"),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.note, func(t *testing.T) {
|
|
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, err := w.Write([]byte(tc.response))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}))
|
|
|
|
defer ts.Close()
|
|
|
|
// delay server start to exercise retry logic
|
|
go func() {
|
|
time.Sleep(time.Second * 5)
|
|
ts.Start()
|
|
}()
|
|
|
|
ctx := context.Background()
|
|
if tc.evalTimeout > 0 {
|
|
ctx, _ = context.WithTimeout(ctx, tc.evalTimeout)
|
|
}
|
|
|
|
q := newQuery(strings.ReplaceAll(tc.query, "%URL%", "http://"+ts.Listener.Addr().String()), time.Now())
|
|
|
|
if tc.cancel != nil {
|
|
q.WithCancel(tc.cancel)
|
|
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
tc.cancel.Cancel()
|
|
}()
|
|
}
|
|
|
|
res, err := q.Run(ctx)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatal("Expected error but got nil")
|
|
}
|
|
|
|
if tc.err != nil && tc.err.Error() != err.Error() {
|
|
t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error())
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error %v", err)
|
|
}
|
|
|
|
if len(res) != 1 {
|
|
t.Fatalf("Expected one result but got %v", len(res))
|
|
}
|
|
|
|
resResponse := res[0]["x"].Value.(ast.Object).Get(ast.StringTerm("raw_body"))
|
|
if ast.String(tc.response).Compare(resResponse.Value) != 0 {
|
|
t.Fatalf("Expected response %v but got %v", tc.response, resResponse.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|