blob: 71fe50de63e893445680d57c586d2383b087e1d4 [file] [edit]
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package async_test
import (
"context"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/cel-go/cel/async"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
type retryableTestErr struct{}
func (retryableTestErr) Error() string { return "retry me" }
func (retryableTestErr) IsRetryable() bool { return true }
type nonRetryableTestErr struct{}
func (nonRetryableTestErr) Error() string { return "do not retry me" }
func (nonRetryableTestErr) IsRetryable() bool { return false }
// buildZeroArgAsync builds a zero-arity async overload from an option and returns its AsyncOp.
func buildZeroArgAsync(t *testing.T, opt decls.OverloadOpt) functions.AsyncOp {
t.Helper()
fnDecl, err := decls.NewFunction("fn", decls.Overload("fn_zero", []*types.Type{}, types.IntType, opt))
if err != nil {
t.Fatalf("NewFunction() failed: %v", err)
}
bindings, err := fnDecl.Bindings()
if err != nil {
t.Fatalf("Bindings() failed: %v", err)
}
for _, b := range bindings {
if b.Async != nil {
return b.Async
}
}
t.Fatal("no async binding produced")
return nil
}
func TestRetryMultipleAttemptsTimerReset(t *testing.T) {
var attempts atomic.Int32
op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
a := attempts.Add(1)
if a < 3 {
return types.WrapErr(retryableTestErr{})
}
return types.Int(100)
}, async.RetryAttempts(4), async.RetryBackoff(5*time.Millisecond)))
res := <-op(context.Background())
if res.Equal(types.Int(100)) != types.True {
t.Fatalf("result = %v, want 100", res)
}
if got := attempts.Load(); got != 3 {
t.Errorf("attempts = %d, want 3", got)
}
}
func TestRetryMaxAttemptsExhausted(t *testing.T) {
var attempts atomic.Int32
op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
a := attempts.Add(1)
return types.WrapErr(fmt.Errorf("retry attempt %d failed: %w", a, retryableTestErr{}))
}, async.RetryAttempts(3), async.RetryBackoff(5*time.Millisecond)))
res := <-op(context.Background())
if !types.IsError(res) {
t.Fatalf("result = %v, want error", res)
}
msg := res.(*types.Err).Error()
if !strings.Contains(msg, "retry attempt 3 failed") {
t.Errorf("result error = %q, want last error 'retry attempt 3 failed'", msg)
}
if got := attempts.Load(); got != 3 {
t.Errorf("attempts = %d, want 3", got)
}
}
func TestRetryBindingCancellation(t *testing.T) {
var attempts atomic.Int32
op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
attempts.Add(1)
return types.WrapErr(retryableTestErr{})
}, async.RetryAttempts(5), async.RetryBackoff(500*time.Millisecond)))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(40 * time.Millisecond)
cancel()
}()
start := time.Now()
var res ref.Val
select {
case res = <-op(ctx):
case <-time.After(2 * time.Second):
t.Fatal("retry op did not return after cancellation")
}
elapsed := time.Since(start)
if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "cancelled") {
t.Errorf("result = %v, want a cancellation error", res)
}
// Cancellation must interrupt the backoff wait rather than running it to completion.
if elapsed >= 500*time.Millisecond {
t.Errorf("retry waited the full backoff (%v); cancellation did not interrupt it", elapsed)
}
}
func TestRetryNonRetryableError(t *testing.T) {
var attempts atomic.Int32
op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
attempts.Add(1)
return types.WrapErr(nonRetryableTestErr{})
}, async.RetryAttempts(5), async.RetryBackoff(10*time.Millisecond)))
res := <-op(context.Background())
if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "do not retry me") {
t.Fatalf("result = %v, want 'do not retry me'", res)
}
if got := attempts.Load(); got != 1 {
t.Errorf("attempts = %d, want 1 (non-retryable error should not be retried)", got)
}
}
func TestRetryStandardError(t *testing.T) {
var attempts atomic.Int32
op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
attempts.Add(1)
return types.NewErr("generic error")
}, async.RetryAttempts(5), async.RetryBackoff(10*time.Millisecond)))
res := <-op(context.Background())
if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "generic error") {
t.Fatalf("result = %v, want 'generic error'", res)
}
if got := attempts.Load(); got != 1 {
t.Errorf("attempts = %d, want 1 (standard error should not be retried)", got)
}
}
type mockAsyncCall struct {
id int64
function string
overload string
}
func (m mockAsyncCall) CallID() int64 { return m.id }
func (m mockAsyncCall) Function() string { return m.function }
func (m mockAsyncCall) Overload() string { return m.overload }
func TestAsyncCallMethods(t *testing.T) {
m := mockAsyncCall{id: 1, function: "f", overload: "o"}
if m.CallID() != 1 {
t.Errorf("got %d, want 1", m.CallID())
}
if m.Function() != "f" {
t.Errorf("got %s, want f", m.Function())
}
if m.Overload() != "o" {
t.Errorf("got %s, want o", m.Overload())
}
}
func TestDrainNone(t *testing.T) {
s := async.DrainNone()
// No completions, calls still pending -> no re-evaluation
if s.NextAction(nil, 1).Reevaluate {
t.Error("DrainNone re-evaluated with nil batch")
}
// One completion -> re-evaluate
if !s.NextAction([]async.Call{mockAsyncCall{}}, 1).Reevaluate {
t.Error("DrainNone did not re-evaluate with 1 completion")
}
// No pending -> re-evaluate regardless of batch
if !s.NextAction(nil, 0).Reevaluate {
t.Error("DrainNone did not re-evaluate when nothing pending")
}
}
func TestDrainAll(t *testing.T) {
s := async.DrainAll()
// Pending calls remain -> no re-evaluation
if s.NextAction([]async.Call{mockAsyncCall{}}, 1).Reevaluate {
t.Error("DrainAll re-evaluated while calls are pending")
}
// No pending calls -> re-evaluate
if !s.NextAction([]async.Call{mockAsyncCall{}}, 0).Reevaluate {
t.Error("DrainAll did not re-evaluate when no calls pending")
}
}
func TestDrainReady(t *testing.T) {
debounce := 10 * time.Millisecond
s := async.DrainReady(debounce)
// No pending calls -> re-evaluate immediately
action := s.NextAction([]async.Call{mockAsyncCall{}}, 0)
if !action.Reevaluate {
t.Error("DrainReady did not re-evaluate when no calls pending")
}
// No completions -> wait indefinitely
action = s.NextAction(nil, 1)
if action.Reevaluate || action.WaitDuration != 0 {
t.Errorf("DrainReady NextAction(nil, 1) = %v, want {false, 0}", action)
}
// Some completions, still pending -> wait for the debounce window
action = s.NextAction([]async.Call{mockAsyncCall{}}, 1)
if action.Reevaluate || action.WaitDuration != debounce {
t.Errorf("DrainReady NextAction(batch, 1) = %v, want {false, %v}", action, debounce)
}
}
func TestTimeoutBindingEnforcesAgainstContextIgnoringOp(t *testing.T) {
// The wrapped op sleeps well past the timeout without consulting its context. TimeoutBinding
// must still return at the deadline rather than waiting for the op to finish.
op := buildZeroArgAsync(t, async.TimeoutBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
time.Sleep(2 * time.Second) // ignores ctx entirely
return types.Int(1)
}, 50*time.Millisecond))
start := time.Now()
var res ref.Val
select {
case res = <-op(context.Background()):
case <-time.After(2 * time.Second):
t.Fatal("TimeoutBinding did not return; the op was not abandoned")
}
elapsed := time.Since(start)
if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "timed out") {
t.Errorf("result = %v, want a timeout error", res)
}
if elapsed >= 500*time.Millisecond {
t.Errorf("TimeoutBinding waited %v; the timeout must abandon a ctx-ignoring op", elapsed)
}
}