From db1660a390c5697e5e2a7235fb8352f280b6ad89 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Wed, 20 May 2026 14:00:06 +0300 Subject: [PATCH] init: portal-common (db + middleware + portal client) --- README.md | 46 +++++++ db/pool.go | 140 +++++++++++++++++++++ go.mod | 21 ++++ go.sum | 26 ++++ middleware/internal_auth.go | 136 +++++++++++++++++++++ portal/client.go | 235 ++++++++++++++++++++++++++++++++++++ 6 files changed, 604 insertions(+) create mode 100644 README.md create mode 100644 db/pool.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 middleware/internal_auth.go create mode 100644 portal/client.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..cff95b5 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# portal-common + +Shared Go-библиотека для микросервисов портала. + +## Состав + +- `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах. +- `middleware/` — `InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков. +- `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user. + +## Использование + +В каждом сервисе: + +```go +import ( + "gitea.estateliga.work/admin/portal-common/db" + "gitea.estateliga.work/admin/portal-common/middleware" + "gitea.estateliga.work/admin/portal-common/portal" +) + +pool, _ := db.ConnectURL(cfg.DatabaseURL) + +r := chi.NewRouter() +r.Use(middleware.InternalAuth(cfg.InternalAPIKey)) + +portalCli := portal.New(cfg.PortalBaseURL, cfg.PortalAPIKey) +``` + +## Dev-режим (без push в Gitea) + +В `go.mod` сервиса добавить `replace`: + +``` +replace gitea.estateliga.work/admin/portal-common => ../portal-common +``` + +Когда библиотека стабилизируется, заменить на pinned тег: + +``` +require gitea.estateliga.work/admin/portal-common v0.1.0 +``` + +## Зачем + +До этого 9 сервисов копировали один в один: pgxpool init, slow-query tracer (500ms threshold), InternalAuth middleware. Tweak'ать tuning централизованно было невозможно. Сейчас изменения идут в одном репо, сервисы пересобираются. diff --git a/db/pool.go b/db/pool.go new file mode 100644 index 0000000..2c9ce49 --- /dev/null +++ b/db/pool.go @@ -0,0 +1,140 @@ +// Package db — общий pgxpool init для микросервисов: единая конфигурация +// connection-pool'а, slow-query tracer, retry на startup. +// +// История: каждый сервис (booking, candidates, tasks, meet, leaders-reports, +// telephony, hhru, webhooks-apps, deals) копировал этот код один в один. +// Разъехавшиеся mode сложно tune'ить централизованно — теперь tweak'аем +// здесь, а сервисы пересобираются. +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PoolConfig — параметры пула. Дефолты идентичны тому, что было в каждом +// сервисе (MaxConns=10, MinConns=2). Slow-query threshold — 500ms. +// +// Если сервис хочет другие лимиты (например, candidates с большим +// объёмом read'ов), можно передать своё значение; nil/zero — берём +// default. +type PoolConfig struct { + DatabaseURL string + MaxConns int32 + MinConns int32 + MaxConnIdleTime time.Duration + MaxConnLifetime time.Duration + SlowQueryThreshold time.Duration + ConnectTimeout time.Duration +} + +// Defaults возвращает PoolConfig с дефолтными значениями, выставлены +// только числовые лимиты — DatabaseURL caller подставляет сам. +func Defaults() PoolConfig { + return PoolConfig{ + MaxConns: 10, + MinConns: 2, + MaxConnIdleTime: 5 * time.Minute, + MaxConnLifetime: 30 * time.Minute, + SlowQueryThreshold: 500 * time.Millisecond, + ConnectTimeout: 10 * time.Second, + } +} + +// Connect открывает pgxpool с заданной конфигурацией и tracer'ом slow-queries. +// Возвращает live-pool после успешного Ping; на ошибке pool закрывается. +func Connect(cfg PoolConfig) (*pgxpool.Pool, error) { + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DatabaseURL is required") + } + def := Defaults() + if cfg.MaxConns == 0 { + cfg.MaxConns = def.MaxConns + } + if cfg.MinConns == 0 { + cfg.MinConns = def.MinConns + } + if cfg.MaxConnIdleTime == 0 { + cfg.MaxConnIdleTime = def.MaxConnIdleTime + } + if cfg.MaxConnLifetime == 0 { + cfg.MaxConnLifetime = def.MaxConnLifetime + } + if cfg.SlowQueryThreshold == 0 { + cfg.SlowQueryThreshold = def.SlowQueryThreshold + } + if cfg.ConnectTimeout == 0 { + cfg.ConnectTimeout = def.ConnectTimeout + } + + poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("parse db config: %w", err) + } + poolCfg.MaxConns = cfg.MaxConns + poolCfg.MinConns = cfg.MinConns + poolCfg.MaxConnIdleTime = cfg.MaxConnIdleTime + poolCfg.MaxConnLifetime = cfg.MaxConnLifetime + poolCfg.ConnConfig.Tracer = &slowQueryTracer{threshold: cfg.SlowQueryThreshold} + + ctx, cancel := context.WithTimeout(context.Background(), cfg.ConnectTimeout) + defer cancel() + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + return pool, nil +} + +// ConnectURL — короткая форма Connect(PoolConfig{DatabaseURL: url}). +// Удобно для сервисов, которым не нужны кастомные лимиты. +func ConnectURL(url string) (*pgxpool.Pool, error) { + cfg := Defaults() + cfg.DatabaseURL = url + return Connect(cfg) +} + +// slowQueryTracer пишет WARN в slog'е для запросов, длиннее threshold. +// Логируется duration_ms, обрезанный SQL и error (если был). Не префиксируем +// сервисом — это делает caller через slog.With() на старте процесса. +type slowQueryTracer struct{ threshold time.Duration } + +type traceCtxKey struct{} +type traceState struct { + start time.Time + sql string +} + +func (t *slowQueryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { + return context.WithValue(ctx, traceCtxKey{}, traceState{start: time.Now(), sql: data.SQL}) +} + +func (t *slowQueryTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) { + st, ok := ctx.Value(traceCtxKey{}).(traceState) + if !ok { + return + } + dur := time.Since(st.start) + if dur < t.threshold { + return + } + sql := st.sql + if len(sql) > 500 { + sql = sql[:500] + "…" + } + attrs := []any{"duration_ms", dur.Milliseconds(), "sql", sql} + if data.Err != nil { + attrs = append(attrs, "error", data.Err) + } + slog.Warn("slow query", attrs...) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21c040f --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +// Shared библиотека для микросервисов портала. +// Содержит: db (pgxpool init + slow-query tracer), middleware (InternalAuth), +// portal (HTTP-клиент portal-сервиса), audit (общий event log). +// +// Используется из tasks/, candidates/, booking/, meet/, leaders-reports/, +// telephony/, hhru/, webhooks-apps/, deals/. Каждый сервис ссылается на +// `gitea.estateliga.work/admin/portal-common` через go.mod replace +// в dev-сборке (см. README.md в репозитории). +module gitea.estateliga.work/admin/portal-common + +go 1.25.7 + +require github.com/jackc/pgx/v5 v5.9.1 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e29ab9 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/middleware/internal_auth.go b/middleware/internal_auth.go new file mode 100644 index 0000000..1c232db --- /dev/null +++ b/middleware/internal_auth.go @@ -0,0 +1,136 @@ +// Package middleware — общая InternalAuth для cross-service запросов +// через portal-proxy. Каждый сервис до этого имел свой клон файла; теперь +// один источник истины. +// +// Контракт: +// - portal-proxy пробрасывает X-Internal-Key (общий с сервисом секрет), +// X-User-Id (UUID логиниувшегося юзера), X-User-Name (для логов/UI), +// X-User-Is-Admin ('1' если у юзера системная админ-роль), плюс +// любые X-User-* кастомные заголовки (роли/scope/subordinates). +// - middleware кладёт всё это в context'е, чтобы handler'ы и нижестоящие +// middleware'ы читали без повторного парса заголовков. +// +// Если сервису нужны кастомные роли — ставим дополнительные middleware +// поверх (см. WithCustomHeaders в этом же пакете). +package middleware + +import ( + "context" + "crypto/subtle" + "net" + "net/http" + "strings" +) + +// contextKey — typedef'нутый string чтобы не сталкиваться с другими пакетами, +// которые могут писать в r.Context() свои ключи. +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + UserNameKey contextKey = "user_name" + IsAdminKey contextKey = "is_admin" + ClientIPKey contextKey = "client_ip" +) + +// InternalAuth возвращает middleware, который: +// - сверяет X-Internal-Key с expectedKey (constant-time compare против +// timing-атак), +// - требует X-User-Id (без него запрос неавторизован), +// - кладёт user_id, user_name, is_admin, client_ip в r.Context(). +// +// Если expectedKey пустой — сервис намеренно отключил internal API; все +// запросы летят с 503. Это удобный «kill switch» для dev без выкатки. +func InternalAuth(expectedKey string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if expectedKey == "" { + http.Error(w, `{"error":"internal api disabled"}`, http.StatusServiceUnavailable) + return + } + gotKey := r.Header.Get("X-Internal-Key") + if len(gotKey) != len(expectedKey) || + subtle.ConstantTimeCompare([]byte(gotKey), []byte(expectedKey)) != 1 { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + userID := r.Header.Get("X-User-Id") + if userID == "" { + http.Error(w, `{"error":"missing X-User-Id header"}`, http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserNameKey, r.Header.Get("X-User-Name")) + ctx = context.WithValue(ctx, IsAdminKey, r.Header.Get("X-User-Is-Admin") == "1") + ctx = context.WithValue(ctx, ClientIPKey, extractClientIP(r)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// WithCustomHeader — фабрика для сервисов, которым нужны дополнительные +// header→context маппинги. Пример: candidates читает X-Candidates-Scope. +// +// Использование: +// r.Use(middleware.InternalAuth(key)) +// r.Use(middleware.WithCustomHeader("X-Candidates-Scope", scopeKey)) +// +// Хендлер потом достаёт через context.Value(scopeKey). +func WithCustomHeader(headerName string, ctxKey any) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := r.Header.Get(headerName) + ctx := r.Context() + if v != "" { + ctx = context.WithValue(ctx, ctxKey, v) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetUserID(ctx context.Context) string { + if v, ok := ctx.Value(UserIDKey).(string); ok { + return v + } + return "" +} + +func GetUserName(ctx context.Context) string { + if v, ok := ctx.Value(UserNameKey).(string); ok { + return v + } + return "" +} + +func IsAdmin(ctx context.Context) bool { + if v, ok := ctx.Value(IsAdminKey).(bool); ok { + return v + } + return false +} + +func GetClientIP(ctx context.Context) string { + if v, ok := ctx.Value(ClientIPKey).(string); ok { + return v + } + return "" +} + +// extractClientIP читает X-Forwarded-For (берём левый — это исходный +// клиент по RFC 7239), fallback на RemoteAddr без порта. +func extractClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if comma := strings.IndexByte(xff, ','); comma >= 0 { + xff = xff[:comma] + } + if ip := strings.TrimSpace(xff); ip != "" { + return ip + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/portal/client.go b/portal/client.go new file mode 100644 index 0000000..7e35e5f --- /dev/null +++ b/portal/client.go @@ -0,0 +1,235 @@ +// Package portal — общий HTTP-клиент portal-сервиса для cross-service +// нужд: directory с кэшем+stale fallback'ом, notification fan-out, +// deactivate user. +// +// Раньше каждый сервис делал свой клон (booking/notifications, +// candidates/directory+deactivate, meet/notifications, leaders-reports/ +// notifications). Теперь — один источник. +package portal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// User — DTO из /api/internal/users (см. portal handler.InternalUserDTO). +// Не все поля нужны каждому клиенту, но дублируем целиком, чтобы был +// единый shape (caller игнорирует лишнее). +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + MiddleName string `json:"middle_name"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + DepartmentID *string `json:"department_id"` + DepartmentName *string `json:"department_name"` + PositionID *string `json:"position_id"` + PositionName *string `json:"position_name"` + WorkGeoCode *string `json:"work_geo_code"` +} + +type Client struct { + baseURL string + apiKey string + hc *http.Client + + // Кэш directory. 5-мин TTL, при ошибке возвращаем stale — temporary + // portal outage не должен валить cross-service flow. + mu sync.Mutex + cache []User + cachedAt time.Time + cacheTTL time.Duration +} + +// New строит клиент с дефолтами (timeout=10s, TTL=5m). Если baseURL или +// apiKey пустые — клиент Disabled, все методы вернут ошибку. +func New(baseURL, apiKey string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + hc: &http.Client{Timeout: 10 * time.Second}, + cacheTTL: 5 * time.Minute, + } +} + +// Enabled — true если конфигурация полная. Caller проверяет перед +// вызовом, чтобы решить «пытаться или skip». +func (c *Client) Enabled() bool { + return c != nil && c.baseURL != "" && c.apiKey != "" +} + +// SetCacheTTL — переопределить TTL directory-кэша (для тестов или +// сервисов с особыми требованиями к свежести). +func (c *Client) SetCacheTTL(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.cacheTTL = d +} + +// ListDirectory возвращает справочник юзеров (с кэшем). При ошибке сети — +// stale (если есть), иначе propagated error. Сетевой 5xx ретраится один +// раз (200ms backoff). +func (c *Client) ListDirectory(ctx context.Context) ([]User, error) { + if !c.Enabled() { + return nil, errors.New("portal client disabled") + } + c.mu.Lock() + if time.Since(c.cachedAt) < c.cacheTTL && c.cache != nil { + out := c.cache + c.mu.Unlock() + return out, nil + } + c.mu.Unlock() + + users, err := c.fetchDirectory(ctx) + if err != nil { + c.mu.Lock() + stale := c.cache + c.mu.Unlock() + if stale != nil { + return stale, fmt.Errorf("fetch directory (using stale cache): %w", err) + } + return nil, err + } + + c.mu.Lock() + c.cache = users + c.cachedAt = time.Now() + c.mu.Unlock() + return users, nil +} + +// CreateNotificationRequest — DTO для /api/internal/notifications. +// Channels пока не enforce'ится сервером — задел на будущий fan-out. +type CreateNotificationRequest struct { + UserID string `json:"user_id"` + Title string `json:"title"` + Message string `json:"message"` + Type string `json:"type,omitempty"` + Link string `json:"link,omitempty"` + Channels []string `json:"channels,omitempty"` +} + +func (c *Client) CreateNotification(ctx context.Context, req CreateNotificationRequest) error { + if !c.Enabled() { + return errors.New("portal client disabled") + } + return c.postJSON(ctx, "/api/internal/notifications", req) +} + +// DeactivateUser помечает юзера is_active=false + invalidate'ит токены. +// 404 от portal'а — already-gone, для caller'а это успех. +func (c *Client) DeactivateUser(ctx context.Context, userID string) error { + if !c.Enabled() { + return errors.New("portal client disabled") + } + if userID == "" { + return errors.New("user id required") + } + url := c.baseURL + "/api/users/" + userID + "/deactivate" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + req.Header.Set("X-Internal-Key", c.apiKey) + resp, err := c.hc.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// postJSON — общий helper для POST /api/* с JSON-телом и X-Internal-Key. +func (c *Client) postJSON(ctx context.Context, path string, payload any) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + url := c.baseURL + path + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Internal-Key", c.apiKey) + resp, err := c.hc.Do(httpReq) + if err != nil { + return fmt.Errorf("post: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("portal %s returned %d: %s", path, resp.StatusCode, string(respBody)) + } + return nil +} + +// fetchDirectory — GET /api/internal/users с retry'ем на 5xx и парсингом +// обоих форматов ответа (массив или {users: []}). +func (c *Client) fetchDirectory(ctx context.Context) ([]User, error) { + url := c.baseURL + "/api/internal/users" + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + time.Sleep(200 * time.Millisecond) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("X-Internal-Key", c.apiKey) + resp, lastErr = c.hc.Do(req) + if lastErr != nil { + continue + } + if resp.StatusCode >= 500 && resp.StatusCode < 600 { + _ = resp.Body.Close() + lastErr = fmt.Errorf("portal 5xx: %d", resp.StatusCode) + continue + } + break + } + if lastErr != nil && resp == nil { + return nil, fmt.Errorf("get directory: %w", lastErr) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body)) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + var asArr []User + if err := json.Unmarshal(raw, &asArr); err == nil { + return asArr, nil + } + var asObj struct { + Users []User `json:"users"` + } + if err := json.Unmarshal(raw, &asObj); err != nil { + return nil, fmt.Errorf("parse directory: %w", err) + } + return asObj.Users, nil +}