Skip to content

Commit af8af34

Browse files
Gate BigQuery classification on an explicit error wrapper (#4309)
The previous classifier change matched bare *googleapi.Error and labelled every match as bigquery. But *googleapi.Error is the shared Go error type for any HTTP-level Google API failure — GCS-outside-BQ, KMS, Pub/Sub, etc. all surface it. A stray googleapi error from anywhere in the codebase would be misattributed to BigQuery, polluting per-source telemetry. Introduce exceptions.BigQueryError as an explicit wrapper that callers in the BigQuery connector use to mark "this error came from a BQ code path", and gate BigQuery classification on its presence. Inside the gate, both SDK error types are inspected because they come from different paths and never co-occur: - *googleapi.Error (HTTP-level, integer Code) — from API call failures like Run, Wait, Metadata, Create, Delete, GCS iterator Next. 401/403/ 404 map to connectivity. - *bigquery.Error (job-level, string Reason like "invalidQuery" or "accessDenied") — produced by JobStatus.Err() when a job is accepted by the API but fails during execution. The Reason is surfaced as the code for diagnostic clarity; we don't classify individual Reasons yet, so the class falls through to ErrorOther with BQ source. Tests updated to wrap inputs in BigQueryError; new negative test asserts that an unwrapped *googleapi.Error from outside the BQ connector now falls through to ErrorSourceOther rather than being mislabelled. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef4894f commit af8af34

4 files changed

Lines changed: 87 additions & 16 deletions

File tree

flow/alerting/classifier.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414
"syscall"
1515

16+
"cloud.google.com/go/bigquery"
1617
chproto "github.com/ClickHouse/ch-go/proto"
1718
"github.com/ClickHouse/clickhouse-go/v2"
1819
"github.com/go-mysql-org/go-mysql/mysql"
@@ -874,16 +875,21 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) {
874875
return ErrorRetryRecoverable, mongoErrorInfo
875876
}
876877

877-
if apiErr, ok := errors.AsType[*googleapi.Error](err); ok {
878+
if _, ok := errors.AsType[*exceptions.BigQueryError](err); ok {
878879
bqErrorInfo := ErrorInfo{
879880
Source: ErrorSourceBigQuery,
880-
Code: strconv.Itoa(apiErr.Code),
881+
Code: "UNKNOWN",
881882
}
882-
switch apiErr.Code {
883-
case 401, // Unauthorized
884-
403, // Forbidden
885-
404: // Not Found (e.g. missing dataset/table/staging bucket)
886-
return ErrorNotifyConnectivity, bqErrorInfo
883+
if apiErr, ok := errors.AsType[*googleapi.Error](err); ok {
884+
bqErrorInfo.Code = strconv.Itoa(apiErr.Code)
885+
switch apiErr.Code {
886+
case 401, // Unauthorized
887+
403, // Forbidden
888+
404: // Not Found (e.g. missing dataset/table/staging bucket)
889+
return ErrorNotifyConnectivity, bqErrorInfo
890+
}
891+
} else if bqErr, ok := errors.AsType[*bigquery.Error](err); ok && bqErr.Reason != "" {
892+
bqErrorInfo.Code = bqErr.Reason
887893
}
888894
return ErrorOther, bqErrorInfo
889895
}

flow/alerting/classifier_test.go

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"syscall"
1111
"testing"
1212

13+
"cloud.google.com/go/bigquery"
1314
"cloud.google.com/go/storage"
1415
chproto "github.com/ClickHouse/ch-go/proto"
1516
"github.com/ClickHouse/clickhouse-go/v2"
@@ -1018,15 +1019,16 @@ func TestMySQLGeometryParseErrorUnknownShouldFallThrough(t *testing.T) {
10181019
}, errInfo)
10191020
}
10201021

1021-
func TestGoogleAPIAuthAndAccessErrorsShouldBeConnectivity(t *testing.T) {
1022+
func TestBigQueryAuthAndAccessErrorsShouldBeConnectivity(t *testing.T) {
10221023
t.Parallel()
10231024

10241025
for _, code := range []int{401, 403, 404} {
10251026
t.Run(strconv.Itoa(code), func(t *testing.T) {
10261027
t.Parallel()
10271028

10281029
apiErr := &googleapi.Error{Code: code, Message: "boom"}
1029-
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("failed to access staging bucket: %w", apiErr))
1030+
err := exceptions.NewBigQueryError(apiErr)
1031+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("failed to access staging bucket: %w", err))
10301032
assert.Equal(t, ErrorNotifyConnectivity, errorClass)
10311033
assert.Equal(t, ErrorInfo{
10321034
Source: ErrorSourceBigQuery,
@@ -1041,28 +1043,73 @@ func TestGoogleAPIAuthAndAccessErrorsShouldBeConnectivity(t *testing.T) {
10411043
// return fmt.Errorf("%w: %w", ErrBucketNotExist, err)
10421044
//
10431045
// `it.Next()` on a missing staging bucket surfaces this exact shape at
1044-
// connectors/bigquery/source.go:59.
1045-
func TestGCSBucketNotExistShouldBeConnectivity(t *testing.T) {
1046+
// connectors/bigquery/source.go:59 (then wrapped in BigQueryError there).
1047+
func TestBigQueryGCSBucketNotExistShouldBeConnectivity(t *testing.T) {
10461048
t.Parallel()
10471049

10481050
apiErr := &googleapi.Error{Code: 404, Message: "Not Found"}
1049-
wrapped := fmt.Errorf("%w: %w", storage.ErrBucketNotExist, apiErr)
1050-
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("failed to access staging bucket: %w", wrapped))
1051+
storageWrapped := fmt.Errorf("%w: %w", storage.ErrBucketNotExist, apiErr)
1052+
err := exceptions.NewBigQueryError(storageWrapped)
1053+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("failed to access staging bucket: %w", err))
10511054
assert.Equal(t, ErrorNotifyConnectivity, errorClass)
10521055
assert.Equal(t, ErrorInfo{
10531056
Source: ErrorSourceBigQuery,
10541057
Code: "404",
10551058
}, errInfo)
10561059
}
10571060

1058-
func TestGoogleAPIUnclassifiedCodeShouldBeOther(t *testing.T) {
1061+
func TestBigQueryUnclassifiedCodeShouldBeOther(t *testing.T) {
10591062
t.Parallel()
10601063

10611064
apiErr := &googleapi.Error{Code: 500, Message: "internal"}
1062-
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("export job failed: %w", apiErr))
1065+
err := exceptions.NewBigQueryError(apiErr)
1066+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("export job failed: %w", err))
10631067
assert.Equal(t, ErrorOther, errorClass)
10641068
assert.Equal(t, ErrorInfo{
10651069
Source: ErrorSourceBigQuery,
10661070
Code: "500",
10671071
}, errInfo)
10681072
}
1073+
1074+
func TestBigQueryErrorWithoutGoogleAPIShouldBeOther(t *testing.T) {
1075+
t.Parallel()
1076+
1077+
err := exceptions.NewBigQueryError(fmt.Errorf("opaque failure"))
1078+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("bq op failed: %w", err))
1079+
assert.Equal(t, ErrorOther, errorClass)
1080+
assert.Equal(t, ErrorInfo{
1081+
Source: ErrorSourceBigQuery,
1082+
Code: "UNKNOWN",
1083+
}, errInfo)
1084+
}
1085+
1086+
// bigquery.Error (with a Reason like "invalidQuery", "accessDenied") is what
1087+
// surfaces from job-level failures e.g. status.Err() on Job.Wait. We surface
1088+
// the Reason as the code for diagnostic clarity even though we don't classify
1089+
// individual Reasons yet.
1090+
func TestBigQueryJobErrorWithReasonShouldUseReasonAsCode(t *testing.T) {
1091+
t.Parallel()
1092+
1093+
jobErr := &bigquery.Error{Reason: "invalidQuery", Message: "bad SQL", Location: "query"}
1094+
err := exceptions.NewBigQueryError(jobErr)
1095+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("export job completed with error: %w", err))
1096+
assert.Equal(t, ErrorOther, errorClass)
1097+
assert.Equal(t, ErrorInfo{
1098+
Source: ErrorSourceBigQuery,
1099+
Code: "invalidQuery",
1100+
}, errInfo)
1101+
}
1102+
1103+
// Unwrapped *googleapi.Error from outside the BigQuery connector must NOT be
1104+
// attributed to BigQuery — only errors explicitly wrapped in BigQueryError are.
1105+
func TestUnwrappedGoogleAPIErrorShouldNotBeBigQuery(t *testing.T) {
1106+
t.Parallel()
1107+
1108+
apiErr := &googleapi.Error{Code: 403, Message: "forbidden"}
1109+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("some other gcp call: %w", apiErr))
1110+
assert.Equal(t, ErrorOther, errorClass)
1111+
assert.Equal(t, ErrorInfo{
1112+
Source: ErrorSourceOther,
1113+
Code: "UNKNOWN",
1114+
}, errInfo)
1115+
}

flow/connectors/bigquery/source.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1313
"github.com/PeerDB-io/peerdb/flow/pkg/common"
14+
"github.com/PeerDB-io/peerdb/flow/shared/exceptions"
1415
)
1516

1617
func (c *BigQueryConnector) ValidateMirrorSource(ctx context.Context, cfg *protos.FlowConnectionConfigsCore) error {
@@ -56,7 +57,7 @@ func (c *BigQueryConnector) ValidateMirrorSource(ctx context.Context, cfg *proto
5657
it := bucket.Objects(ctx, &storage.Query{Prefix: stagingPath.QueryPrefix()})
5758
_, err = it.Next()
5859
if err != nil && !errors.Is(err, iterator.Done) {
59-
return fmt.Errorf("failed to access staging bucket: %w", err)
60+
return fmt.Errorf("failed to access staging bucket: %w", exceptions.NewBigQueryError(err))
6061
}
6162

6263
return nil

flow/shared/exceptions/bigquery.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package exceptions
2+
3+
type BigQueryError struct {
4+
error
5+
}
6+
7+
func NewBigQueryError(err error) *BigQueryError {
8+
return &BigQueryError{err}
9+
}
10+
11+
func (e *BigQueryError) Error() string {
12+
return "BigQuery Error: " + e.error.Error()
13+
}
14+
15+
func (e *BigQueryError) Unwrap() error {
16+
return e.error
17+
}

0 commit comments

Comments
 (0)