Skip to content

Commit 7f5832e

Browse files
Add experimental core framework to replace existing API call execution. (#1318)
## What changes are proposed in this pull request? This PR introduces a new experimental API framework that abstracts the transport layer from API calls through a composable, interface-based design. The core changes include: - New `Call` abstraction: Defines API calls as simple functions (`func(context.Context) error`) that are transport-agnostic. - Configurable execution framework: The `Execute` function orchestrates API calls with pluggable retry, rate limiting, and timeout behaviors. - Retry mechanism with exponential backoff: Implements a flexible `Retrier` interface with configurable `BackoffPolicy` supporting jitter and exponential growth. - Options pattern: Uses functional options (`WithRetrier`, `WithTimeout`, `WithLimiter`) for clean, composable configuration. - Context-aware operations: Full support for context cancellation and timeouts throughout the execution pipeline. This change is foundational to rewriting the SDK from the ground up with a transport-agnostic approach. The current SDK was designed with multiple code paths and minimal code generation, making it tightly coupled to HTTP transport. This new framework enables: - Transport abstraction: API calls become logical operations independent of whether they use HTTP, gRPC, or other protocols. - Code generation focus: Moving transport considerations to the code generation level rather than scattered throughout the SDK. - Composition over complexity: Small, focused interfaces that can be composed together rather than monolithic implementations. - Consistency: Unified retry, rate limiting, and timeout behavior across all API calls. - Testability: Clean interfaces make it easy to mock and test different scenarios. ## How is this tested? The PR includes comprehensive unit tests covering. NO_CHANGELOG=true
1 parent da0f8e4 commit 7f5832e

6 files changed

Lines changed: 730 additions & 0 deletions

File tree

experimental/api/api.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Package api provides utilities to make API calls against the Databricks API.
2+
//
3+
// This package draws inspiration from the AWS and GCP SDKs.
4+
package api
5+
6+
import (
7+
"context"
8+
"time"
9+
)
10+
11+
// Call represents a call to a Databricks API.
12+
type Call func(context.Context) error
13+
14+
// Execute makes a call to a Databricks API using the given options.
15+
func Execute(ctx context.Context, call Call, opts ...Option) error {
16+
options := Options{}
17+
for _, opt := range opts {
18+
if err := opt.Apply(&options); err != nil {
19+
return err
20+
}
21+
}
22+
return execute(ctx, call, options, sleep)
23+
}
24+
25+
// sleep sleeps for the given duration. It is mostly equivalent to time.Sleep,
26+
// but can be interrupted by ctx.Done() if the context completes before the
27+
// duration elapses.
28+
func sleep(ctx context.Context, d time.Duration) error {
29+
t := time.NewTimer(d)
30+
select {
31+
case <-ctx.Done():
32+
t.Stop()
33+
return ctx.Err()
34+
case <-t.C:
35+
return nil
36+
}
37+
}
38+
39+
// sleeper is a convenience type for readability.
40+
type sleeper func(ctx context.Context, d time.Duration) error
41+
42+
// execute is the actual implementation of Execute. Its purpose is to ease
43+
// testing by providing a convenient way to mock the sleeping logic.
44+
func execute(ctx context.Context, apiCall Call, opts Options, sleep sleeper) error {
45+
// Optionally update the context with the timeout. If the context already
46+
// has a deadline, that deadline is updated to the minimum of the context's
47+
// deadline and the timeout.
48+
if opts.timeout != 0 {
49+
c, f := context.WithTimeout(ctx, opts.timeout)
50+
defer f()
51+
ctx = c
52+
}
53+
54+
// Get a new retrier for this specific execution. This is instantiated
55+
// lazilly if and if the first call execution returns an error.
56+
var retrier Retrier
57+
58+
for {
59+
if opts.rateLimiter != nil { // no limiter == no wait
60+
if err := opts.rateLimiter.Wait(ctx); err != nil {
61+
return err
62+
}
63+
}
64+
65+
err := apiCall(ctx)
66+
if err == nil {
67+
return nil // nothing to retry
68+
}
69+
70+
if retrier == nil {
71+
if opts.retrier != nil {
72+
retrier = opts.retrier() // lazily instantiate the retrier
73+
}
74+
if retrier == nil {
75+
return err // no retrier == no retry
76+
}
77+
}
78+
delay, ok := retrier.IsRetriable(err)
79+
if !ok {
80+
return err // not retriable
81+
}
82+
if err := sleep(ctx, delay); err != nil {
83+
return err
84+
}
85+
}
86+
}

experimental/api/api_test.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
)
9+
10+
type mockRetrier struct {
11+
fn func(error) (time.Duration, bool)
12+
}
13+
14+
func (m *mockRetrier) IsRetriable(err error) (time.Duration, bool) {
15+
return m.fn(err)
16+
}
17+
18+
type mockLimiter struct {
19+
fn func(context.Context) error
20+
}
21+
22+
func (m *mockLimiter) Wait(ctx context.Context) error {
23+
return m.fn(ctx)
24+
}
25+
26+
func TestExecute_success(t *testing.T) {
27+
testCases := []struct {
28+
name string
29+
call Call
30+
opts []Option
31+
}{
32+
{
33+
name: "successful call no options",
34+
call: func(ctx context.Context) error { return nil },
35+
opts: []Option{},
36+
},
37+
{
38+
name: "successful call with timeout option",
39+
call: func(ctx context.Context) error { return nil },
40+
opts: []Option{WithTimeout(5 * time.Second)},
41+
},
42+
{
43+
name: "successful call with retrier option",
44+
call: func(ctx context.Context) error { return nil },
45+
opts: []Option{WithRetrier(func() Retrier {
46+
return &mockRetrier{fn: func(err error) (time.Duration, bool) { return 0, true }}
47+
})},
48+
},
49+
{
50+
name: "successful call with limiter option",
51+
call: func(ctx context.Context) error { return nil },
52+
opts: []Option{WithLimiter(&mockLimiter{fn: func(ctx context.Context) error { return nil }})},
53+
},
54+
}
55+
56+
for _, tc := range testCases {
57+
t.Run(tc.name, func(t *testing.T) {
58+
got := Execute(context.Background(), tc.call, tc.opts...)
59+
if got != nil {
60+
t.Errorf("Execute(): got error %v, want nil", got)
61+
}
62+
})
63+
}
64+
}
65+
66+
func TestExecute_retries(t *testing.T) {
67+
// Default retrier that only retries if the error is a retriableError.
68+
retriableError := errors.New("retriable error")
69+
nonRetriableError := errors.New("non-retriable error")
70+
retrier := &mockRetrier{fn: func(err error) (time.Duration, bool) {
71+
return 0, err == retriableError
72+
}}
73+
74+
testCases := []struct {
75+
name string
76+
callErrors []error // sequence of errors the call should return
77+
retrier Retrier
78+
wantErr error
79+
wantCallCount int
80+
}{
81+
{
82+
name: "no retrier - fail immediately",
83+
callErrors: []error{retriableError},
84+
retrier: nil, // no retrier
85+
wantErr: retriableError,
86+
wantCallCount: 1,
87+
},
88+
{
89+
name: "non-retriable error - fail immediately",
90+
callErrors: []error{nonRetriableError},
91+
retrier: retrier,
92+
wantErr: nonRetriableError,
93+
wantCallCount: 1,
94+
},
95+
{
96+
name: "retriable error - retry once then succeed",
97+
callErrors: []error{retriableError, nil},
98+
retrier: retrier,
99+
wantCallCount: 2,
100+
},
101+
{
102+
name: "retriable error - retry multiple times then succeed",
103+
callErrors: []error{retriableError, retriableError, retriableError, nil},
104+
retrier: retrier,
105+
wantCallCount: 4,
106+
},
107+
{
108+
name: "retriable error - retry then fail with non-retriable",
109+
callErrors: []error{retriableError, nonRetriableError},
110+
retrier: retrier,
111+
wantErr: nonRetriableError,
112+
wantCallCount: 2,
113+
},
114+
}
115+
116+
for _, tc := range testCases {
117+
t.Run(tc.name, func(t *testing.T) {
118+
gotCallCount := 0
119+
call := func(ctx context.Context) error {
120+
err := tc.callErrors[gotCallCount]
121+
gotCallCount++
122+
return err
123+
}
124+
125+
gotErr := Execute(context.Background(), call, WithRetrier(func() Retrier { return tc.retrier }))
126+
127+
if gotCallCount != tc.wantCallCount {
128+
t.Errorf("Execute(): got call count %d, want %d", gotCallCount, tc.wantCallCount)
129+
}
130+
if !errors.Is(gotErr, tc.wantErr) {
131+
t.Errorf("Execute(): got error %v, want %v", gotErr, tc.wantErr)
132+
}
133+
})
134+
}
135+
}
136+
137+
func TestExecute_timeout(t *testing.T) {
138+
testCases := []struct {
139+
name string
140+
ctxTimeout time.Duration
141+
optTimeout time.Duration
142+
callDelay time.Duration
143+
wantTimeout bool
144+
}{
145+
{
146+
name: "no timeout - call succeeds",
147+
callDelay: 10 * time.Millisecond,
148+
wantTimeout: false,
149+
},
150+
{
151+
name: "context timeout - call times out",
152+
ctxTimeout: 10 * time.Millisecond,
153+
callDelay: 50 * time.Millisecond,
154+
wantTimeout: true,
155+
},
156+
{
157+
name: "option timeout - call times out",
158+
optTimeout: 10 * time.Millisecond,
159+
callDelay: 50 * time.Millisecond,
160+
wantTimeout: true,
161+
},
162+
{
163+
name: "minimum timeout - context timeout",
164+
ctxTimeout: 10 * time.Millisecond,
165+
optTimeout: 100 * time.Millisecond, // would not timeout
166+
callDelay: 50 * time.Millisecond,
167+
wantTimeout: true,
168+
},
169+
{
170+
name: "minimum timeout - option timeout",
171+
ctxTimeout: 100 * time.Millisecond, // would not timeout
172+
optTimeout: 10 * time.Millisecond,
173+
callDelay: 50 * time.Millisecond,
174+
wantTimeout: true,
175+
},
176+
}
177+
178+
for _, tc := range testCases {
179+
t.Run(tc.name, func(t *testing.T) {
180+
// Cancellable call that succeed after the call delay or returns
181+
// the context error if the context is cancelled.
182+
call := func(ctx context.Context) error {
183+
select {
184+
case <-time.After(tc.callDelay):
185+
return nil
186+
case <-ctx.Done():
187+
return ctx.Err()
188+
}
189+
}
190+
191+
ctx := context.Background()
192+
if tc.ctxTimeout > 0 {
193+
c, f := context.WithTimeout(ctx, tc.ctxTimeout)
194+
defer f()
195+
ctx = c
196+
}
197+
198+
var opts []Option
199+
if tc.optTimeout > 0 {
200+
opts = append(opts, WithTimeout(tc.optTimeout))
201+
}
202+
203+
got := Execute(ctx, call, opts...)
204+
205+
if tc.wantTimeout && !errors.Is(got, context.DeadlineExceeded) {
206+
t.Errorf("Execute(): got error %v, want context.DeadlineExceeded", got)
207+
}
208+
if !tc.wantTimeout && got != nil {
209+
t.Errorf("Execute(): got error %v, want nil", got)
210+
}
211+
})
212+
}
213+
}
214+
215+
func TestExecute_rateLimiting(t *testing.T) {
216+
testError := errors.New("rate limited")
217+
218+
// This tests purely focuses on verifying that the limiter is called and
219+
// that errors are handled correctly.
220+
testCases := []struct {
221+
name string
222+
limiter Limiter
223+
wantErr error
224+
wantCalls int
225+
}{
226+
{
227+
name: "no limiter - call proceeds",
228+
wantCalls: 1,
229+
},
230+
{
231+
name: "limiter allows - call proceeds",
232+
limiter: &mockLimiter{fn: func(ctx context.Context) error { return nil }},
233+
wantCalls: 1,
234+
},
235+
{
236+
name: "limiter blocks - call fails",
237+
limiter: &mockLimiter{fn: func(ctx context.Context) error { return testError }},
238+
wantErr: testError,
239+
},
240+
}
241+
242+
for _, tc := range testCases {
243+
t.Run(tc.name, func(t *testing.T) {
244+
gotCalls := 0
245+
call := func(ctx context.Context) error {
246+
gotCalls++
247+
return nil
248+
}
249+
250+
var opts []Option
251+
if tc.limiter != nil {
252+
opts = append(opts, WithLimiter(tc.limiter))
253+
}
254+
255+
got := Execute(context.Background(), call, opts...)
256+
257+
if gotCalls != tc.wantCalls {
258+
t.Errorf("Execute(): got call count %d, want %d", gotCalls, tc.wantCalls)
259+
}
260+
if !errors.Is(got, tc.wantErr) {
261+
t.Errorf("Execute(): got error %v, want %v", got, tc.wantErr)
262+
}
263+
})
264+
}
265+
}
266+
267+
func TestExecute_contextCancellation(t *testing.T) {
268+
// Call and retrier for infinite retries.
269+
testErr := errors.New("test error")
270+
call := func(ctx context.Context) error {
271+
return testErr // always fail
272+
}
273+
retrier := &mockRetrier{
274+
fn: func(err error) (time.Duration, bool) {
275+
return 5 * time.Millisecond, true // always retry
276+
},
277+
}
278+
279+
ctx, cancel := context.WithCancel(context.Background())
280+
time.AfterFunc(10*time.Millisecond, cancel) // cancel after 10ms
281+
got := Execute(ctx, call, WithRetrier(func() Retrier { return retrier }))
282+
283+
if !errors.Is(got, context.Canceled) {
284+
t.Errorf("Execute(): got error %v, want %v", got, context.Canceled)
285+
}
286+
}
287+
288+
// errorOption implements an Option that always returns an error.
289+
type errorOption struct {
290+
err error
291+
}
292+
293+
func (e errorOption) Apply(*Options) error {
294+
return e.err
295+
}
296+
297+
func TestExecute_optionErrors(t *testing.T) {
298+
testErr := errors.New("option error")
299+
300+
call := func(ctx context.Context) error { return nil }
301+
got := Execute(context.Background(), call, errorOption{err: testErr})
302+
303+
if !errors.Is(got, testErr) {
304+
t.Errorf("Execute(): got error %v, want %v", got, testErr)
305+
}
306+
}

0 commit comments

Comments
 (0)