Skip to content

Commit dddb26a

Browse files
fix(adbc): re-authenticate on Unauthenticated instead of failing permanently (#73)
* fix(adbc): re-authenticate on Unauthenticated instead of failing permanently The ADBC FlightSQL connection authenticates only once, when it is opened: the Basic-auth handshake yields a server-side session token that is then reused for every prepared statement on that connection. If the server invalidates that session (e.g. it expires after a period of inactivity), every subsequent prepared statement fails with Unauthenticated. Because SqlWithParams classified Unauthenticated as a permanent error, the connection never recovered on its own — the only remedy was to recreate the SpiceClient (restart the process). Detect authentication failures (adbc.StatusUnauthenticated / Unauthorized, with a message fallback) and, when one occurs, re-open the ADBC connection to perform a fresh handshake and retry the query once. Re-initialization is guarded by a mutex and a staleness check so concurrent callers re-open the connection at most once. The plain (non-parameterized) Sql/Query path is unaffected: it re-authenticates on every call, so it was already immune. * fix(adbc): address review comments on re-authentication - Fix the data race on c.adbcClient: capture the connection once under adbcMu via ensureADBC() and thread it through execADBCWithBackoff / queryADBCWithParams / bindParameters, so queries never re-read c.adbcClient while reinitADBC may be replacing it. reinitADBC returns the fresh connection. - isADBCAuthError now matches both adbc.Error and *adbc.Error before the message fallback (new isADBCAuthStatus helper). - reinitADBC logs a warning when closeADBC() fails instead of discarding it. - Add *adbc.Error (pointer) cases to TestIsADBCAuthError. --------- Co-authored-by: Luke Kim <80174+lukekim@users.noreply.github.com>
1 parent 7e40890 commit dddb26a

3 files changed

Lines changed: 183 additions & 9 deletions

File tree

adbc.go

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gospice
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log"
78
"strings"
@@ -122,17 +123,60 @@ func (c *SpiceClient) closeADBC() error {
122123
// reader, err := client.SqlWithParams(ctx, "SELECT * FROM table WHERE id = $1 AND name = $2", 123, "test")
123124
// reader, err := client.SqlWithParams(ctx, "SELECT * FROM table WHERE ts = $1", TimestampParam(ts, arrow.Microsecond, "UTC"))
124125
func (c *SpiceClient) SqlWithParams(ctx context.Context, sql string, params ...any) (array.RecordReader, error) {
126+
// Capture the ADBC connection to use under the mutex so a concurrent
127+
// re-authentication cannot swap it out from under this query.
128+
used, err := c.ensureADBC()
129+
if err != nil {
130+
return nil, fmt.Errorf("ADBC client is not initialized and failed to initialize: %w", err)
131+
}
132+
133+
rdr, err := c.execADBCWithBackoff(ctx, used, sql, params...)
134+
if err != nil && isADBCAuthError(err) {
135+
// The ADBC connection authenticates only once, when it is opened: a
136+
// Basic-auth handshake yields a server-side session token that is then
137+
// reused for every prepared statement on that connection. That session
138+
// can be invalidated server-side (e.g. expired after a period of
139+
// inactivity), after which the cached token is rejected on every
140+
// subsequent request and the connection cannot recover on its own.
141+
// Re-open the connection to perform a fresh handshake, then retry once.
142+
fresh, reinitErr := c.reinitADBC(used)
143+
if reinitErr != nil {
144+
return nil, fmt.Errorf("ADBC re-authentication failed: %w (original error: %v)", reinitErr, err)
145+
}
146+
rdr, err = c.execADBCWithBackoff(ctx, fresh, sql, params...)
147+
}
148+
if err != nil {
149+
return nil, err
150+
}
151+
152+
return rdr, nil
153+
}
154+
155+
// ensureADBC returns the current ADBC connection, lazily initializing it if
156+
// necessary. The read and initialization run under adbcMu so the returned
157+
// pointer is a consistent snapshot even while another goroutine may be
158+
// re-authenticating via reinitADBC.
159+
func (c *SpiceClient) ensureADBC() (*ADBCClient, error) {
160+
c.adbcMu.Lock()
161+
defer c.adbcMu.Unlock()
162+
125163
if c.adbcClient == nil {
126-
// Try lazy initialization
127164
if err := c.initADBC(); err != nil {
128-
return nil, fmt.Errorf("ADBC client is not initialized and failed to initialize: %w", err)
165+
return nil, err
129166
}
130167
}
168+
return c.adbcClient, nil
169+
}
131170

171+
// execADBCWithBackoff runs a parameterized ADBC query, retrying transient
172+
// (e.g. Unavailable / Internal) failures with the client's backoff policy.
173+
// Authentication failures are returned as-is (not retried here) so the caller
174+
// can re-establish the connection before retrying.
175+
func (c *SpiceClient) execADBCWithBackoff(ctx context.Context, client *ADBCClient, sql string, params ...any) (array.RecordReader, error) {
132176
var rdr array.RecordReader
133177
err := backoff.Retry(func() error {
134178
var err error
135-
rdr, err = c.queryADBCWithParams(ctx, sql, params...)
179+
rdr, err = c.queryADBCWithParams(ctx, client, sql, params...)
136180
if err != nil {
137181
st, ok := status.FromError(err)
138182
if ok {
@@ -158,14 +202,69 @@ func (c *SpiceClient) SqlWithParams(ctx context.Context, sql string, params ...a
158202
return rdr, nil
159203
}
160204

205+
// isADBCAuthError reports whether err indicates the ADBC connection's
206+
// credentials/session were rejected by the server (as opposed to a transient
207+
// or query error). Such failures are not recoverable on the existing
208+
// connection and require re-opening it to perform a fresh handshake.
209+
func isADBCAuthError(err error) bool {
210+
if err == nil {
211+
return false
212+
}
213+
// Match both the value and pointer forms of adbc.Error, since the driver
214+
// or wrapping layers may return either.
215+
var adbcErr adbc.Error
216+
if errors.As(err, &adbcErr) && isADBCAuthStatus(adbcErr.Code) {
217+
return true
218+
}
219+
var adbcErrPtr *adbc.Error
220+
if errors.As(err, &adbcErrPtr) && adbcErrPtr != nil && isADBCAuthStatus(adbcErrPtr.Code) {
221+
return true
222+
}
223+
// Fall back to matching the message in case the typed error is not
224+
// propagated through the wrapping chain.
225+
msg := err.Error()
226+
return strings.Contains(msg, "Unauthenticated") || strings.Contains(msg, "Invalid credentials")
227+
}
228+
229+
// isADBCAuthStatus reports whether an ADBC status code indicates the server
230+
// rejected the connection's credentials or session.
231+
func isADBCAuthStatus(code adbc.Status) bool {
232+
return code == adbc.StatusUnauthenticated || code == adbc.StatusUnauthorized
233+
}
234+
235+
// reinitADBC closes and re-opens the ADBC connection so that the next query
236+
// performs a fresh authentication handshake, returning the connection to use
237+
// for the retry. The stale argument is the connection the caller observed
238+
// failing; if another goroutine has already replaced it with a live one, that
239+
// connection is reused so the connection is only re-opened once.
240+
func (c *SpiceClient) reinitADBC(stale *ADBCClient) (*ADBCClient, error) {
241+
c.adbcMu.Lock()
242+
defer c.adbcMu.Unlock()
243+
244+
// Another caller may have already re-opened the connection we observed as
245+
// stale; if so, reuse theirs rather than churning the connection again.
246+
if c.adbcClient != stale && c.adbcClient != nil {
247+
return c.adbcClient, nil
248+
}
249+
250+
if closeErr := c.closeADBC(); closeErr != nil {
251+
log.Printf("warning: failed to close stale ADBC connection during re-authentication: %v", closeErr)
252+
}
253+
c.adbcClient = nil
254+
if err := c.initADBC(); err != nil {
255+
return nil, err
256+
}
257+
return c.adbcClient, nil
258+
}
259+
161260
// queryADBCWithParams executes a parameterized query using ADBC with prepare/execute pattern
162-
func (c *SpiceClient) queryADBCWithParams(ctx context.Context, sql string, params ...any) (array.RecordReader, error) {
163-
if c.adbcClient == nil || c.adbcClient.conn == nil {
261+
func (c *SpiceClient) queryADBCWithParams(ctx context.Context, client *ADBCClient, sql string, params ...any) (array.RecordReader, error) {
262+
if client == nil || client.conn == nil {
164263
return nil, fmt.Errorf("ADBC connection is not initialized")
165264
}
166265

167266
// Create a prepared statement
168-
stmt, err := c.adbcClient.conn.NewStatement()
267+
stmt, err := client.conn.NewStatement()
169268
if err != nil {
170269
return nil, fmt.Errorf("error creating statement: %w", err)
171270
}
@@ -188,7 +287,7 @@ func (c *SpiceClient) queryADBCWithParams(ctx context.Context, sql string, param
188287

189288
// Bind parameters if provided
190289
if len(params) > 0 {
191-
if err := c.bindParameters(stmt, params...); err != nil {
290+
if err := c.bindParameters(client, stmt, params...); err != nil {
192291
return nil, fmt.Errorf("error binding parameters: %w", err)
193292
}
194293
}
@@ -203,7 +302,7 @@ func (c *SpiceClient) queryADBCWithParams(ctx context.Context, sql string, param
203302
}
204303

205304
// bindParameters binds parameters to an ADBC statement
206-
func (c *SpiceClient) bindParameters(stmt adbc.Statement, params ...any) error {
305+
func (c *SpiceClient) bindParameters(client *ADBCClient, stmt adbc.Statement, params ...any) error {
207306
if len(params) == 0 {
208307
return nil
209308
}
@@ -249,7 +348,7 @@ func (c *SpiceClient) bindParameters(stmt adbc.Statement, params ...any) error {
249348
schema := arrow.NewSchema(fields, nil)
250349

251350
// Create a record builder using the reusable allocator
252-
bldr := array.NewRecordBuilder(c.adbcClient.mem, schema)
351+
bldr := array.NewRecordBuilder(client.mem, schema)
253352
defer bldr.Release()
254353

255354
// Add values to the builders

adbc_reauth_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package gospice
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/apache/arrow-adbc/go/adbc"
9+
)
10+
11+
func TestIsADBCAuthError(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
err error
15+
want bool
16+
}{
17+
{
18+
name: "nil",
19+
err: nil,
20+
want: false,
21+
},
22+
{
23+
name: "adbc unauthenticated",
24+
err: adbc.Error{Code: adbc.StatusUnauthenticated, Msg: "[FlightSQL] Invalid credentials"},
25+
want: true,
26+
},
27+
{
28+
name: "adbc unauthorized",
29+
err: adbc.Error{Code: adbc.StatusUnauthorized, Msg: "[FlightSQL] forbidden"},
30+
want: true,
31+
},
32+
{
33+
name: "wrapped adbc unauthenticated (the prepared-statement failure shape)",
34+
err: fmt.Errorf("error preparing statement: %w",
35+
adbc.Error{Code: adbc.StatusUnauthenticated, Msg: "[FlightSQL] Invalid credentials (Unauthenticated; Prepare)"}),
36+
want: true,
37+
},
38+
{
39+
name: "pointer adbc unauthorized",
40+
err: &adbc.Error{Code: adbc.StatusUnauthorized, Msg: "[FlightSQL] forbidden"},
41+
want: true,
42+
},
43+
{
44+
name: "wrapped pointer adbc unauthenticated",
45+
err: fmt.Errorf("error preparing statement: %w",
46+
&adbc.Error{Code: adbc.StatusUnauthenticated, Msg: "[FlightSQL] Invalid credentials"}),
47+
want: true,
48+
},
49+
{
50+
name: "adbc internal is not an auth error",
51+
err: adbc.Error{Code: adbc.StatusInternal, Msg: "boom"},
52+
want: false,
53+
},
54+
{
55+
name: "plain error mentioning Unauthenticated (fallback)",
56+
err: errors.New("rpc error: code = Unauthenticated desc = invalid"),
57+
want: true,
58+
},
59+
{
60+
name: "unrelated transient error",
61+
err: errors.New("connection refused"),
62+
want: false,
63+
},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
if got := isADBCAuthError(tt.err); got != tt.want {
69+
t.Errorf("isADBCAuthError(%v) = %v, want %v", tt.err, got, tt.want)
70+
}
71+
})
72+
}
73+
}

client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"os"
1212
"strings"
13+
"sync"
1314
"time"
1415

1516
"github.com/apache/arrow-go/v18/arrow/flight"
@@ -40,6 +41,7 @@ type SpiceClient struct {
4041

4142
flightClient flight.Client
4243
adbcClient *ADBCClient
44+
adbcMu sync.Mutex // guards re-initialization of adbcClient
4345
httpClient http.Client
4446
backoffPolicy backoff.BackOff
4547
maxRetries uint

0 commit comments

Comments
 (0)