Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion pkg/connector/event_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ func (connector *Okta) ListEvents(

logs, resp, err := connector.client.LogEvent.GetLogs(ctx, qp)
if err != nil {
return nil, nil, nil, err
// Route through the shared handler like every other call site; bare, this
// returned an SDK error carrying no grpc code, no status, and no prefix.
return nil, nil, nil, fmt.Errorf("okta-connectorv2: failed to list system log events: %w", handleOktaResponseError(resp, err))
}

// MJP each log is not guaranteed to result in a v2.Event anymore, but it's still likely?
Expand Down
123 changes: 123 additions & 0 deletions pkg/connector/get_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package connector

import (
"io"
"net/http"
"strings"
"testing"
"unicode/utf8"

"github.com/okta/okta-sdk-golang/v2/okta"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// getError's five call sites all return its error verbatim, so it has to carry
// the status and the connector prefix itself. An empty body used to surface as a
// bare "unexpected end of JSON input".
func TestGetError(t *testing.T) {
t.Parallel()

oktaResp := func(statusCode int, body string) *okta.Response {
return &okta.Response{Response: &http.Response{
StatusCode: statusCode,
Status: http.StatusText(statusCode),
Body: io.NopCloser(strings.NewReader(body)),
}}
}

t.Run("empty body reports the status", func(t *testing.T) {
t.Parallel()
_, err := getError(oktaResp(http.StatusBadRequest, ""))
if err == nil {
t.Fatal("expected an error")
}
for _, want := range []string{"okta-connectorv2:", http.StatusText(http.StatusBadRequest)} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error = %q, want it to contain %q", err, want)
}
}
})

t.Run("non-JSON body is excerpted, not dropped", func(t *testing.T) {
t.Parallel()
_, err := getError(oktaResp(http.StatusBadGateway, "<html>gateway</html>"))
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "gateway") {
t.Errorf("error = %q, want the body excerpt included", err)
}
})

t.Run("decodable body still parses", func(t *testing.T) {
t.Parallel()
got, err := getError(oktaResp(http.StatusForbidden, `{"errorCode":"E0000006","errorSummary":"denied"}`))
if err != nil {
t.Fatalf("getError: %v", err)
}
if got.ErrorCode != AccessDeniedErrorCode {
t.Errorf("ErrorCode = %q, want %q", got.ErrorCode, AccessDeniedErrorCode)
}
})
}

// A fixed byte offset can split a multi-byte rune; the excerpt lands in an error
// message and in log fields, so it has to stay valid UTF-8.
func TestBodyExcerpt_TruncatesOnRuneBoundary(t *testing.T) {
t.Parallel()

// A three-byte rune so the byte limit does not divide evenly and the cut lands
// mid-rune. Two-byte runes would tile it exactly and prove nothing.
body := strings.Repeat("€", errorBodyExcerptLimit)
got := bodyExcerpt([]byte(body))

if !utf8.ValidString(got) {
t.Errorf("excerpt is not valid UTF-8: %q", got)
}
if !strings.HasSuffix(got, "...") {
t.Errorf("excerpt = %q, want it marked as truncated", got)
}
if short := bodyExcerpt([]byte("€")); short != "€" {
t.Errorf("short body = %q, want it returned whole", short)
}
}

// Both getError paths must classify by HTTP status, so the same upstream failure
// gets the same gRPC code whether or not Okta sent a parseable body. The five call
// sites return this error verbatim, so an unparseable body used to reach the sync
// as codes.Unknown while a parseable one became PermissionDenied.
func TestGetError_ClassifiesByHTTPStatus(t *testing.T) {
t.Parallel()

tests := []struct {
name string
statusCode int
want codes.Code
}{
{name: "forbidden", statusCode: http.StatusForbidden, want: codes.PermissionDenied},
{name: "rate limited", statusCode: http.StatusTooManyRequests, want: codes.Unavailable},
{name: "not found", statusCode: http.StatusNotFound, want: codes.NotFound},
{name: "bad request", statusCode: http.StatusBadRequest, want: codes.InvalidArgument},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
resp := &okta.Response{Response: &http.Response{
StatusCode: tc.statusCode,
Status: http.StatusText(tc.statusCode),
Body: io.NopCloser(strings.NewReader("<html>not json</html>")),
}}
_, err := getError(resp)
if status.Code(err) != tc.want {
t.Errorf("code = %s, want %s (error: %v)", status.Code(err), tc.want, err)
}
// The message still has to carry the prefix and status for the logs.
for _, sub := range []string{"okta-connectorv2:", http.StatusText(tc.statusCode)} {
if !strings.Contains(err.Error(), sub) {
t.Errorf("error = %q, want it to contain %q", err, sub)
}
}
})
}
}
46 changes: 44 additions & 2 deletions pkg/connector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/url"
"strings"
"unicode/utf8"

"github.com/conductorone/baton-sdk/pkg/pagination"
"github.com/conductorone/baton-sdk/pkg/uhttp"
Expand Down Expand Up @@ -103,21 +104,41 @@ func responseToContext(token *pagination.Token, resp *okta.Response) (*responseC
}, nil
}

// errorBodyExcerptLimit caps how much of an unparseable error body is echoed
// back into the returned error.
const errorBodyExcerptLimit = 200

func getError(response *okta.Response) (okta.Error, error) {
var errOkta okta.Error
bytes, err := io.ReadAll(response.Body)
if err != nil {
return okta.Error{}, err
return okta.Error{}, bodyReadError(response, "read error body", err)
}

// An empty or non-JSON body used to surface as a bare "unexpected end of JSON
// input" with no status code, which made an empty 400 indistinguishable from
// an empty 403 in logs.
err = json.Unmarshal(bytes, &errOkta)
if err != nil {
return okta.Error{}, err
return okta.Error{}, bodyReadError(response, fmt.Sprintf("unparseable error body %q", bodyExcerpt(bytes)), err)
}
Comment on lines 113 to 124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the new messages add the status text and the prefix, but still carry no gRPC code. All five call sites (group.go:211, role.go:456/498, app.go:407/479) return this verbatim, so an unparseable 403 body surfaces as codes.Unknown, while a parseable one reaches handleOktaResponseErrorWrapErrorsWithRateLimitInfo(GrpcCodeFromHTTPStatus(...)) and gets PermissionDenied. Same upstream failure, two different codes depending only on whether Okta sent a body. Consider wrapping with uhttp.WrapErrors(uhttp.GrpcCodeFromHTTPStatus(response.StatusCode), ...) here so the retry/surface decision is driven by the status either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fffa61c — the asymmetry was real: an unparseable 403 reached the sync as codes.Unknown while a parseable one became PermissionDenied for the same upstream failure, and a 429 was retryable only if Okta happened to send a body.

getError now routes both failure paths through uhttp.WrapErrors(uhttp.GrpcCodeFromHTTPStatus(response.StatusCode), ...) in a small bodyReadError helper, keeping the connector prefix, the status text, and the body excerpt in the message. TestGetError_ClassifiesByHTTPStatus covers 403/429/404/400, asserting both the code and that the message still carries prefix and status.


return errOkta, nil
}

// bodyReadError builds the error getError's callers return verbatim. The gRPC code
// comes from the HTTP status so that an unreadable body and a readable one produce
// the same classification for the same upstream failure -- a parseable body reaches
// handleOktaResponseError and gets a code, and without this an unparseable one
// would surface as codes.Unknown.
func bodyReadError(response *okta.Response, what string, err error) error {
return uhttp.WrapErrors(
uhttp.GrpcCodeFromHTTPStatus(response.StatusCode),
fmt.Sprintf("okta-connectorv2: %s: %s", response.Status, what),
err,
)
}

// https://developer.okta.com/docs/reference/error-codes/
var oktaErrToGRPCError = map[string]codes.Code{
"E0000006": codes.PermissionDenied,
Expand All @@ -126,6 +147,27 @@ var oktaErrToGRPCError = map[string]codes.Code{
"E0000011": codes.Unauthenticated,
}

func bodyExcerpt(body []byte) string {
excerpt := strings.TrimSpace(string(body))
if len(excerpt) > errorBodyExcerptLimit {
return truncateAtRuneBoundary(excerpt, errorBodyExcerptLimit) + "..."
}
return excerpt
}

// truncateAtRuneBoundary cuts s to at most maxBytes without splitting a rune, so
// the excerpt reaching an error message or a log field stays valid UTF-8.
func truncateAtRuneBoundary(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
cut := maxBytes
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}

func handleOktaResponseError(resp *okta.Response, err error) error {
if err == nil {
return nil
Expand Down
Loading
Loading