b1d1281f44
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
112 lines
4.6 KiB
Go
112 lines
4.6 KiB
Go
package configsync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/kandev/kandev/internal/github"
|
|
"github.com/kandev/kandev/internal/gitlab"
|
|
)
|
|
|
|
// Neutral fetch error classes. Every provider-specific error the fetch layer
|
|
// can see (currently *github.GitHubAPIError and *gitlab.APIError) is
|
|
// converted to exactly one of these before it reaches the reconciler, so the
|
|
// rest of this package never branches on a provider type.
|
|
var (
|
|
// ErrNotFound means the requested path does not exist at the configured
|
|
// ref. For an optional subdirectory (agents/, skills/, projects/,
|
|
// routines/) this is not fatal — it just means zero entities of that
|
|
// kind. For the configured root path itself it fails the sync.
|
|
ErrNotFound = errors.New("config sync: path not found")
|
|
// ErrUnavailable means the provider could not be reached, is
|
|
// rate-limiting, or refused the request for an auth/permission reason
|
|
// that the workspace owner can plausibly fix (expired token, missing
|
|
// scope). Fails the sync; the poller will retry on its own schedule.
|
|
ErrUnavailable = errors.New("config sync: provider unavailable")
|
|
// ErrUnreadable is the residue class: anything the classifier cannot
|
|
// place in ErrNotFound or ErrUnavailable (malformed response, an
|
|
// unexpected status code, a non-provider transport error). Fails the
|
|
// sync like ErrUnavailable, but is reported distinctly because it is
|
|
// unlikely to self-resolve on retry alone.
|
|
ErrUnreadable = errors.New("config sync: path unreadable")
|
|
)
|
|
|
|
// classifyFetchErr converts a provider-specific listing/content error into
|
|
// one of the three neutral classes above. A nil input returns nil.
|
|
func classifyFetchErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ghErr *github.GitHubAPIError
|
|
if errors.As(err, &ghErr) {
|
|
return classifyStatusCode(ghErr.StatusCode, err)
|
|
}
|
|
var glErr *gitlab.APIError
|
|
if errors.As(err, &glErr) {
|
|
return classifyStatusCode(glErr.StatusCode, err)
|
|
}
|
|
// AC-OFFICE-CONFIG-SYNC-002.4a: a timeout or transport failure (DNS,
|
|
// connection reset, TLS, a context deadline) is Unavailable, not residue
|
|
// — the repository could not be reached at all. Both PAT clients' get()
|
|
// wraps a bare http.Client.Do failure in *url.Error, which is what this
|
|
// checks for, rather than matching net.Error's Timeout() alone: a
|
|
// connection-refused error has no timeout but is equally "could not
|
|
// reach the provider".
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return joinNeutral(ErrUnavailable, err)
|
|
}
|
|
// The gh-CLI client reports the same class of failure differently: a
|
|
// non-zero `gh` exit with no HTTP status in stderr, wrapped bare (not as
|
|
// a *url.Error) by GHClient.runGH. github.IsConnectivityError matches
|
|
// that shape by the network-failure text gh CLI prints to stderr.
|
|
if github.IsConnectivityError(err) {
|
|
return joinNeutral(ErrUnavailable, err)
|
|
}
|
|
// Any other error shape the client returned bare — most commonly a
|
|
// response body it received but could not decode. The repository WAS
|
|
// reached, so this is residue rather than Unavailable.
|
|
return joinNeutral(ErrUnreadable, err)
|
|
}
|
|
|
|
// classifyStatusCode maps an HTTP status code to a neutral class. 404 is the
|
|
// unambiguous "not found" signal both providers use for a missing path. 401
|
|
// (bad/expired credential), 403 (missing scope or, on GitHub, secondary rate
|
|
// limiting), and 429 (explicit rate limiting) are all conditions a workspace
|
|
// owner can act on by re-authenticating or waiting, so they classify as
|
|
// ErrUnavailable together with any 5xx from the provider itself. Everything
|
|
// else (400, 422, an unexpected 3xx, ...) is residue: ErrUnreadable.
|
|
func classifyStatusCode(statusCode int, err error) error {
|
|
switch {
|
|
case statusCode == http.StatusNotFound:
|
|
return joinNeutral(ErrNotFound, err)
|
|
case statusCode == http.StatusUnauthorized,
|
|
statusCode == http.StatusForbidden,
|
|
statusCode == http.StatusTooManyRequests,
|
|
statusCode >= http.StatusInternalServerError:
|
|
return joinNeutral(ErrUnavailable, err)
|
|
default:
|
|
return joinNeutral(ErrUnreadable, err)
|
|
}
|
|
}
|
|
|
|
// joinNeutral wraps the original error behind the neutral sentinel so
|
|
// errors.Is(result, ErrNotFound) (etc.) works while %w / logs still show the
|
|
// underlying provider detail.
|
|
func joinNeutral(class, err error) error {
|
|
return &neutralError{class: class, cause: err}
|
|
}
|
|
|
|
type neutralError struct {
|
|
class error
|
|
cause error
|
|
}
|
|
|
|
func (e *neutralError) Error() string { return e.class.Error() + ": " + e.cause.Error() }
|
|
func (e *neutralError) Is(target error) bool {
|
|
return target == e.class
|
|
}
|
|
func (e *neutralError) Unwrap() error { return e.cause }
|