diff --git a/commons/tenant-manager/client/client.go b/commons/tenant-manager/client/client.go index 6b1e44a2..93510d95 100644 --- a/commons/tenant-manager/client/client.go +++ b/commons/tenant-manager/client/client.go @@ -62,8 +62,12 @@ type Client struct { httpClientOnce sync.Once logger libLog.Logger serviceAPIKey string - cache cache.ConfigCache - cacheTTL time.Duration + // bearerTokenProvider supplies the service-account bearer token for + // RBAC-gated write operations (e.g. CreateTenant). When nil, no + // Authorization header is sent (the read methods rely on X-API-Key only). + bearerTokenProvider TokenProvider + cache cache.ConfigCache + cacheTTL time.Duration // allowInsecureHTTP permits http:// URLs when set to true. // By default, only https:// URLs are accepted unless explicitly opted in @@ -307,12 +311,14 @@ func isServerError(statusCode int) bool { return statusCode >= http.StatusInternalServerError } -// truncateBody returns the body as a string, truncated to maxLen bytes with a -// "...(truncated)" suffix if the body exceeds maxLen. This prevents large +// truncateBody returns the body as a string, truncated to 512 bytes with a +// "...(truncated)" suffix if the body exceeds that limit. This prevents large // response bodies from being logged or included in error messages. // The truncation point is adjusted to the last valid UTF-8 rune boundary // to avoid splitting multi-byte characters. -func truncateBody(body []byte, maxLen int) string { +func truncateBody(body []byte) string { + const maxLen = 512 + if len(body) <= maxLen { return string(body) } @@ -413,7 +419,7 @@ func (c *Client) handleGetTenantConfigStatus( c.logger.Log(ctx, libLog.LevelError, "tenant manager returned error", libLog.Int("status", statusCode), - libLog.String("body", truncateBody(body, 512)), + libLog.String("body", truncateBody(body)), ) libOpentelemetry.HandleSpanError(span, "Tenant Manager returned error", fmt.Errorf("status %d", statusCode)) @@ -498,7 +504,7 @@ func (c *Client) GetTenantConfig(ctx context.Context, tenantID, service string, libOpentelemetry.InjectHTTPContext(ctx, req.Header) // Execute request - // #nosec G704 -- baseURL is validated at construction time and not user-controlled + // #nosec G107 -- baseURL is validated at construction time and not user-controlled resp, err := c.httpClient.Do(req) if err != nil { c.recordFailure() @@ -618,7 +624,7 @@ func (c *Client) GetActiveTenantsByService(ctx context.Context, service string) libOpentelemetry.InjectHTTPContext(ctx, req.Header) // Execute request - // #nosec G704 -- baseURL is validated at construction time and not user-controlled + // #nosec G107 -- baseURL is validated at construction time and not user-controlled resp, err := c.httpClient.Do(req) if err != nil { c.recordFailure() @@ -652,7 +658,7 @@ func (c *Client) GetActiveTenantsByService(ctx context.Context, service string) logger.Log(ctx, libLog.LevelError, "tenant manager returned error", libLog.Int("status", resp.StatusCode), - libLog.String("body", truncateBody(body, 512)), + libLog.String("body", truncateBody(body)), ) libOpentelemetry.HandleSpanError(span, "Tenant Manager returned error", fmt.Errorf("status %d", resp.StatusCode)) diff --git a/commons/tenant-manager/client/coverage_boost_test.go b/commons/tenant-manager/client/coverage_boost_test.go index 025716c1..23aa3998 100644 --- a/commons/tenant-manager/client/coverage_boost_test.go +++ b/commons/tenant-manager/client/coverage_boost_test.go @@ -168,7 +168,7 @@ func TestTruncateBody_ShortBody(t *testing.T) { t.Parallel() body := []byte("short body") - result := truncateBody(body, 512) + result := truncateBody(body) assert.Equal(t, "short body", result) } @@ -180,9 +180,8 @@ func TestTruncateBody_LongBody(t *testing.T) { body[i] = 'x' } - result := truncateBody(body, 512) - assert.True(t, len(result) <= 512+len("... (truncated)")) - assert.Contains(t, result, "truncated") + result := truncateBody(body) + assert.Equal(t, string(body[:512])+"...(truncated)", result) } // ------------------------------------------------------------------- diff --git a/commons/tenant-manager/client/create_tenant.go b/commons/tenant-manager/client/create_tenant.go new file mode 100644 index 00000000..b785f17f --- /dev/null +++ b/commons/tenant-manager/client/create_tenant.go @@ -0,0 +1,206 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/LerianStudio/lib-commons/v6/commons/tenant-manager/core" + observability "github.com/LerianStudio/lib-observability/v2" + libLog "github.com/LerianStudio/lib-observability/v2/log" + libOpentelemetry "github.com/LerianStudio/lib-observability/v2/tracing" + "go.opentelemetry.io/otel/trace" +) + +// TokenProvider returns a bearer token for authenticating a write request to the +// Tenant Manager. It is called once per request, so an implementation is free to +// cache and refresh an OAuth client-credentials token behind it. +type TokenProvider func(ctx context.Context) (string, error) + +// WithBearerTokenProvider configures the source of the service-account bearer +// token sent on RBAC-gated write requests (CreateTenant). The provider is +// invoked per request. When unset, write requests carry no Authorization header +// (only the always-required X-API-Key, which NewClient rejects an empty value +// for). +func WithBearerTokenProvider(provider TokenProvider) ClientOption { + return func(c *Client) { + c.bearerTokenProvider = provider + } +} + +// TenantOwner is the initial owner identity provisioned with a new tenant. The +// password is supplied pre-hashed (bcrypt) — the plaintext is never transported. +type TenantOwner struct { + Email string `json:"email"` + PasswordHash string `json:"passwordHash,omitempty"` + EmailVerified bool `json:"emailVerified"` +} + +// CreateTenantRequest is the POST /v1/tenants payload. +type CreateTenantRequest struct { + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Environment string `json:"environment"` + Isolation string `json:"isolation"` + Metadata map[string]string `json:"metadata,omitempty"` + Owner TenantOwner `json:"owner"` +} + +// CreateTenantResponse is the tenant record returned by a successful create (or +// by an idempotent repeat create of an existing tenant). +type CreateTenantResponse struct { + ID string `json:"id"` + Slug string `json:"slug"` + Status string `json:"status"` +} + +// CreateTenant provisions a tenant via the Tenant Manager's POST /v1/tenants +// endpoint, which is RBAC-gated (backoffice:tm:tenants:write). It sends the +// service-account bearer token from the configured provider plus the X-API-Key +// header, and mirrors the read methods' transport contract: circuit-breaker +// gating, a size-limited response body, and trace-context injection. +// +// The Tenant Manager is idempotent by tenant identity, returning the existing +// tenant with 200 OK on a repeat create; this method treats 200 and 201 as +// success. A 5xx is recorded as a service failure (feeds the circuit breaker); +// a 4xx is a valid round-trip that resets the failure counter. +func (c *Client) CreateTenant(ctx context.Context, req CreateTenantRequest) (*CreateTenantResponse, error) { + c.httpClientOnce.Do(func() { + if c.httpClient == nil { + c.httpClient = newDefaultHTTPClient() + } + }) + + logger, tracer, _, _ := observability.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "tenantmanager.client.create_tenant") + defer span.End() + + if err := c.checkCircuitBreaker(); err != nil { + logger.Log(ctx, libLog.LevelWarn, "circuit breaker open, failing fast") + libOpentelemetry.HandleSpanBusinessErrorEvent(span, "Circuit breaker open", err) + + return nil, err + } + + payload, err := json.Marshal(req) + if err != nil { + libOpentelemetry.HandleSpanError(span, "Failed to marshal request", err) + + return nil, fmt.Errorf("failed to marshal create tenant request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/tenants", bytes.NewReader(payload)) + if err != nil { + libOpentelemetry.HandleSpanError(span, "Failed to create HTTP request", err) + + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + if c.serviceAPIKey != "" { + httpReq.Header.Set("X-API-Key", c.serviceAPIKey) + } + + if c.bearerTokenProvider != nil { + token, tokenErr := c.bearerTokenProvider(ctx) + if tokenErr != nil { + libOpentelemetry.HandleSpanError(span, "Failed to acquire bearer token", tokenErr) + + return nil, fmt.Errorf("acquire service-account token: %w", tokenErr) + } + + httpReq.Header.Set("Authorization", "Bearer "+token) + } + + libOpentelemetry.InjectHTTPContext(ctx, httpReq.Header) + + // #nosec G107 -- baseURL is validated at construction time and not user-controlled + resp, err := c.httpClient.Do(httpReq) + if err != nil { + c.recordFailure() + libOpentelemetry.HandleSpanError(span, "HTTP request failed", err) + + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize)) + if err != nil { + c.recordFailure() + libOpentelemetry.HandleSpanError(span, "Failed to read response body", err) + + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, c.handleCreateTenantStatus(ctx, span, logger, resp.StatusCode, body) + } + + var out CreateTenantResponse + if err := json.Unmarshal(body, &out); err != nil { + libOpentelemetry.HandleSpanError(span, "Failed to parse response", err) + + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + c.recordSuccess() + logger.Log(ctx, libLog.LevelInfo, "successfully created tenant", libLog.String("slug", out.Slug)) + + return &out, nil +} + +// handleCreateTenantStatus maps a non-success create response to a typed error, +// mirroring handleGetTenantConfigStatus: a 409 is a conflict (ErrTenantConflict), +// any other 4xx is a valid round-trip carrying the truncated body, and a 5xx is a +// service failure that feeds the circuit breaker. Only 5xx records a failure; 4xx +// resets the consecutive-failure counter. Anything else (202, 3xx, ...) is +// out-of-contract: surfaced as an error without touching the breaker counters. +func (c *Client) handleCreateTenantStatus(ctx context.Context, span trace.Span, logger libLog.Logger, statusCode int, body []byte) error { + switch { + case statusCode == http.StatusConflict: + c.recordSuccess() + logger.Log(ctx, libLog.LevelWarn, "tenant already exists", + libLog.String("body", truncateBody(body)), + ) + libOpentelemetry.HandleSpanBusinessErrorEvent(span, "Tenant conflict", core.ErrTenantConflict) + + return fmt.Errorf("%w: %s", core.ErrTenantConflict, truncateBody(body)) + case isServerError(statusCode): + c.recordFailure() + logger.Log(ctx, libLog.LevelError, "tenant manager returned error", + libLog.Int("status", statusCode), + libLog.String("body", truncateBody(body)), + ) + libOpentelemetry.HandleSpanError(span, "Tenant Manager returned error", fmt.Errorf("status %d", statusCode)) + + return fmt.Errorf("tenant manager returned status %d for create tenant: %s", statusCode, truncateBody(body)) + case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError: + // Any other 4xx: a valid round-trip (resets the breaker), surfaced with the + // truncated body so the caller can diagnose the rejection. + c.recordSuccess() + logger.Log(ctx, libLog.LevelError, "tenant manager rejected create", + libLog.Int("status", statusCode), + libLog.String("body", truncateBody(body)), + ) + libOpentelemetry.HandleSpanBusinessErrorEvent(span, "Tenant Manager rejected create", fmt.Errorf("status %d", statusCode)) + + return fmt.Errorf("tenant manager returned status %d for create tenant: %s", statusCode, truncateBody(body)) + default: + // Out-of-contract status (202, 3xx, ...): neither a business rejection nor a + // service failure — leave the breaker counters untouched (mirrors + // handleGetTenantConfigStatus's default branch). + logger.Log(ctx, libLog.LevelError, "tenant manager returned unexpected status", + libLog.Int("status", statusCode), + libLog.String("body", truncateBody(body)), + ) + libOpentelemetry.HandleSpanError(span, "Tenant Manager returned unexpected status", fmt.Errorf("status %d", statusCode)) + + return fmt.Errorf("tenant manager returned unexpected status %d for create tenant: %s", statusCode, truncateBody(body)) + } +} diff --git a/commons/tenant-manager/client/create_tenant_test.go b/commons/tenant-manager/client/create_tenant_test.go new file mode 100644 index 00000000..3b928746 --- /dev/null +++ b/commons/tenant-manager/client/create_tenant_test.go @@ -0,0 +1,252 @@ +//go:build unit + +package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LerianStudio/lib-commons/v6/commons/tenant-manager/core" +) + +func validCreateTenantRequest() CreateTenantRequest { + return CreateTenantRequest{ + Name: "Acme Payments", + Slug: "acme-payments", + Environment: "production", + Isolation: "shared", + Metadata: map[string]string{"origin": "self-serve"}, + Owner: TenantOwner{ + Email: "founder@acme.com", + PasswordHash: "$2a$10$abcdefghijklmnopqrstuv", + EmailVerified: true, + }, + } +} + +func TestClient_CreateTenant_Success(t *testing.T) { + var gotBody CreateTenantRequest + + var gotAuth, gotAPIKey, gotMethod, gotPath, gotContentType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotAPIKey = r.Header.Get("X-API-Key") + gotContentType = r.Header.Get("Content-Type") + + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &gotBody)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(CreateTenantResponse{ + ID: "tenant-123", + Slug: "acme-payments", + Status: "active", + }) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithBearerTokenProvider(func(context.Context) (string, error) { + return "service-account-jwt", nil + })) + + resp, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, "tenant-123", resp.ID) + assert.Equal(t, "acme-payments", resp.Slug) + assert.Equal(t, "active", resp.Status) + + assert.Equal(t, http.MethodPost, gotMethod) + assert.Equal(t, "/v1/tenants", gotPath) + assert.Equal(t, "application/json", gotContentType) + assert.Equal(t, "Bearer service-account-jwt", gotAuth, "RBAC-gated write must carry the service-account bearer") + assert.Equal(t, "test-api-key", gotAPIKey, "X-API-Key still sent for parity with the read methods") + + assert.Equal(t, "Acme Payments", gotBody.Name) + assert.Equal(t, "acme-payments", gotBody.Slug) + assert.Equal(t, "production", gotBody.Environment) + assert.Equal(t, "shared", gotBody.Isolation) + assert.Equal(t, "self-serve", gotBody.Metadata["origin"]) + assert.Equal(t, "founder@acme.com", gotBody.Owner.Email) + assert.True(t, gotBody.Owner.EmailVerified, "owner email is born verified") + assert.NotEmpty(t, gotBody.Owner.PasswordHash) +} + +func TestClient_CreateTenant_ExistingTenant_Idempotent(t *testing.T) { + // The Tenant Manager is idempotent by tenant identity: a repeat create returns + // the existing tenant with 200 OK. The client must treat that as success. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(CreateTenantResponse{ID: "tenant-123", Slug: "acme-payments", Status: "active"}) + })) + defer server.Close() + + client := mustNewClient(t, server.URL) + + resp, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "tenant-123", resp.ID) +} + +func TestClient_CreateTenant_NoBearerProvider_NoAuthHeader(t *testing.T) { + var gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(CreateTenantResponse{ID: "t1"}) + })) + defer server.Close() + + client := mustNewClient(t, server.URL) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.NoError(t, err) + assert.Empty(t, gotAuth, "no bearer provider configured -> no Authorization header") +} + +func TestClient_CreateTenant_BearerProviderError(t *testing.T) { + sentinel := errors.New("token acquisition failed") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("request must not be sent when the bearer token cannot be acquired") + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithBearerTokenProvider(func(context.Context) (string, error) { + return "", sentinel + })) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) +} + +func TestClient_CreateTenant_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithCircuitBreaker(1, 30*time.Second)) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + + // A 5xx counts as a service failure and trips the breaker on the next call. + _, err = client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.ErrorIs(t, err, core.ErrCircuitBreakerOpen) +} + +func TestClient_CreateTenant_MalformedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("{not-json")) + })) + defer server.Close() + + client := mustNewClient(t, server.URL) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse response") +} + +func TestClient_CreateTenant_NetworkError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := server.URL + server.Close() // close immediately so the dial fails + + client := mustNewClient(t, url) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.Contains(t, err.Error(), "execute request") +} + +func TestClient_CreateTenant_Conflict_TypedSentinel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"slug already taken"}`)) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithCircuitBreaker(1, 30*time.Second)) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.ErrorIs(t, err, core.ErrTenantConflict, "a 409 must map to the typed conflict sentinel") + + // A 409 is a valid 4xx round-trip: it must NOT trip the breaker. + assert.NotEqual(t, cbOpen, client.cbState) +} + +func TestClient_CreateTenant_BadRequest_IncludesTruncatedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte("invalid company name")) + })) + defer server.Close() + + client := mustNewClient(t, server.URL) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid company name", "a generic 4xx error must carry the (truncated) response body") +} + +func TestClient_CreateTenant_UnexpectedStatus_DoesNotTouchBreaker(t *testing.T) { + // An out-of-contract status (202, 3xx, ...) is neither a business rejection + // nor a service failure: it must not reset the consecutive-failure counter + // (mirrors handleGetTenantConfigStatus's default branch). + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("queued")) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithCircuitBreaker(2, 30*time.Second)) + client.cbFailures = 1 // simulate a prior 5xx + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + assert.NotErrorIs(t, err, core.ErrTenantConflict) + assert.Contains(t, err.Error(), "unexpected status 202") + assert.Equal(t, 1, client.cbFailures, "unexpected status must neither reset nor advance the breaker counter") +} + +func TestClient_CreateTenant_BadRequest_NotBreaker(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid slug")) + })) + defer server.Close() + + client := mustNewClient(t, server.URL, WithCircuitBreaker(1, 30*time.Second)) + + _, err := client.CreateTenant(context.Background(), validCreateTenantRequest()) + require.Error(t, err) + + // A 4xx is a valid round-trip: it must NOT trip the breaker. + assert.NotEqual(t, cbOpen, client.cbState) +} diff --git a/commons/tenant-manager/client/extra_test.go b/commons/tenant-manager/client/extra_test.go index fcdd4ce4..30dd07c0 100644 --- a/commons/tenant-manager/client/extra_test.go +++ b/commons/tenant-manager/client/extra_test.go @@ -211,19 +211,17 @@ func TestTruncateBody(t *testing.T) { t.Run("short body is unchanged", func(t *testing.T) { body := []byte("short") - assert.Equal(t, "short", truncateBody(body, 256)) + assert.Equal(t, "short", truncateBody(body)) }) t.Run("long body is truncated", func(t *testing.T) { - body := make([]byte, 300) + body := make([]byte, 600) for i := range body { body[i] = 'x' } - result := truncateBody(body, 256) - // Truncated to 256 bytes + "...(truncated)" suffix - assert.LessOrEqual(t, len(result), 270, "result should fit within maxLen + suffix") - assert.Contains(t, result, "truncated", "truncated body should contain truncation marker") + result := truncateBody(body) + assert.Equal(t, string(body[:512])+"...(truncated)", result) }) } diff --git a/commons/tenant-manager/client/metadata.go b/commons/tenant-manager/client/metadata.go index 4ca70c65..30d90faa 100644 --- a/commons/tenant-manager/client/metadata.go +++ b/commons/tenant-manager/client/metadata.go @@ -67,7 +67,7 @@ func (c *Client) GetTenantMetadata(ctx context.Context, tenantID string) (map[st // Inject trace context into outgoing HTTP headers for distributed tracing. libOpentelemetry.InjectHTTPContext(ctx, req.Header) - // #nosec G704 -- baseURL is validated at construction time and not user-controlled + // #nosec G107 -- baseURL is validated at construction time and not user-controlled resp, err := c.httpClient.Do(req) if err != nil { c.recordFailure() @@ -101,7 +101,7 @@ func (c *Client) GetTenantMetadata(ctx context.Context, tenantID string) (map[st logger.Log(ctx, libLog.LevelError, "tenant manager returned error", libLog.Int("status", resp.StatusCode), - libLog.String("body", truncateBody(body, 512)), + libLog.String("body", truncateBody(body)), ) libOpentelemetry.HandleSpanError(span, "Tenant Manager returned error", fmt.Errorf("status %d", resp.StatusCode)) diff --git a/commons/tenant-manager/core/errors.go b/commons/tenant-manager/core/errors.go index 15543d64..6eaf380f 100644 --- a/commons/tenant-manager/core/errors.go +++ b/commons/tenant-manager/core/errors.go @@ -44,6 +44,13 @@ func IsNilInterface(v any) bool { // ErrTenantNotFound is returned when the tenant is not found in Tenant Manager. var ErrTenantNotFound = errors.New("tenant not found") +// ErrTenantConflict is returned when a tenant create is rejected with HTTP 409 +// (e.g., the slug or identity already belongs to a different tenant). It is a +// valid 4xx round-trip, not a service failure, so it does not trip the circuit +// breaker. Callers use errors.Is(err, core.ErrTenantConflict) to distinguish a +// conflict from an opaque failure. +var ErrTenantConflict = errors.New("tenant already exists") + // ErrServiceNotConfigured is returned when the service is not configured for the tenant. var ErrServiceNotConfigured = errors.New("service not configured for tenant")