Skip to content

Commit 8578111

Browse files
authored
feat: Add ProviderConnection domain model with circuit breaker (#1308)
* feat: Add ProviderConnection domain model with circuit breaker Introduces the ProviderConnection aggregate for the operational-gateway service with: - Protocol, CircuitState, and HealthStatus enums - AuthConfig interface with APIKeyAuth, BasicAuth, OAuth2Auth, HMACAuth, and MTLSAuth implementations (all store secret refs, not raw values) - RetryPolicy and RateLimitConfig value objects - Circuit breaker methods: RecordSuccess, RecordFailure, TripCircuit, AttemptReset, IsAvailable (closed→open→half-open→closed cycle) - UpdateHealthStatus with timestamping - Full TDD test coverage: 27 tests covering all state transitions * fix: Change BackoffMultiplier to float64 and guard TripCircuit idempotency - BackoffMultiplier is a dimensionless scaling factor (e.g. 2.0x), not a time.Duration; multiplying two durations yields nanoseconds-squared - TripCircuit now preserves the original CircuitOpenedAt when the circuit is already open so open duration is measured from the first failure * fix: Align auth configs with proto, add Protocol validation and threshold guard - BasicAuth: username is now a plain string (not a secret ref), matching proto - OAuth2Auth: client_id is now a plain string (not a secret ref), matching proto - HMACAuth: SignatureHeader replaces HeaderName to match proto signature_header field - MTLSAuth: fields renamed to ClientCertRef/ClientKeyRef/CACertRef; adds optional CACertRef for provider server cert verification, matching proto ca_cert_secret_ref - NewProviderConnection: validates Protocol against known values (ErrInvalidProtocol) - RecordFailure: returns ErrInvalidThreshold when threshold <= 0 - RecordSuccess: resets FailureCount in CLOSED state (consecutive failure tracking) - Tests: 32 tests covering all new behaviour including proto-aligned field names --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 36e5af0 commit 8578111

2 files changed

Lines changed: 822 additions & 0 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
// Package domain contains the operational-gateway domain model.
2+
package domain
3+
4+
import (
5+
"errors"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
)
10+
11+
// Protocol represents the communication protocol used to connect to a provider.
12+
type Protocol string
13+
14+
const (
15+
// ProtocolHTTPS is the HTTPS REST protocol.
16+
ProtocolHTTPS Protocol = "HTTPS"
17+
// ProtocolGRPC is the gRPC protocol.
18+
ProtocolGRPC Protocol = "GRPC"
19+
// ProtocolWebhook is the outbound webhook protocol.
20+
ProtocolWebhook Protocol = "WEBHOOK"
21+
// ProtocolMQTT is the MQTT messaging protocol.
22+
ProtocolMQTT Protocol = "MQTT"
23+
// ProtocolAMQP is the AMQP messaging protocol.
24+
ProtocolAMQP Protocol = "AMQP"
25+
)
26+
27+
// validProtocols is the set of accepted Protocol values for constructor validation.
28+
var validProtocols = map[Protocol]struct{}{
29+
ProtocolHTTPS: {},
30+
ProtocolGRPC: {},
31+
ProtocolWebhook: {},
32+
ProtocolMQTT: {},
33+
ProtocolAMQP: {},
34+
}
35+
36+
// CircuitState represents the current state of the circuit breaker.
37+
type CircuitState string
38+
39+
const (
40+
// CircuitStateClosed means the circuit is closed and requests flow normally.
41+
CircuitStateClosed CircuitState = "CLOSED"
42+
// CircuitStateOpen means the circuit is open and requests are blocked.
43+
CircuitStateOpen CircuitState = "OPEN"
44+
// CircuitStateHalfOpen means the circuit is allowing a probe request to test recovery.
45+
CircuitStateHalfOpen CircuitState = "HALF_OPEN"
46+
)
47+
48+
// HealthStatus represents the observed health of a provider connection.
49+
type HealthStatus string
50+
51+
const (
52+
// HealthStatusUnknown means no health check has been performed yet.
53+
HealthStatusUnknown HealthStatus = "UNKNOWN"
54+
// HealthStatusHealthy means the provider is responding normally.
55+
HealthStatusHealthy HealthStatus = "HEALTHY"
56+
// HealthStatusDegraded means the provider is responding but with elevated latency or errors.
57+
HealthStatusDegraded HealthStatus = "DEGRADED"
58+
// HealthStatusUnhealthy means the provider is not responding or returning errors.
59+
HealthStatusUnhealthy HealthStatus = "UNHEALTHY"
60+
)
61+
62+
// Sentinel errors for domain validation.
63+
var (
64+
// ErrTenantIDRequired is returned when the tenant ID is empty.
65+
ErrTenantIDRequired = errors.New("tenant ID is required")
66+
// ErrProviderNameRequired is returned when the provider name is empty.
67+
ErrProviderNameRequired = errors.New("provider name is required")
68+
// ErrBaseURLRequired is returned when the base URL is empty.
69+
ErrBaseURLRequired = errors.New("base URL is required")
70+
// ErrAuthConfigRequired is returned when the auth config is nil.
71+
ErrAuthConfigRequired = errors.New("auth config is required")
72+
// ErrInvalidProtocol is returned when an unsupported protocol value is provided.
73+
ErrInvalidProtocol = errors.New("invalid protocol")
74+
// ErrInvalidThreshold is returned when a failure threshold of zero or less is used.
75+
ErrInvalidThreshold = errors.New("threshold must be greater than zero")
76+
)
77+
78+
// AuthConfig is the interface implemented by all authentication configuration types.
79+
// Secret-valued fields store references (resolved at dispatch time via SecretStore).
80+
// Non-secret fields (e.g., usernames, client IDs) are stored as plain values.
81+
type AuthConfig interface {
82+
// AuthType returns a string identifying the authentication mechanism.
83+
AuthType() string
84+
}
85+
86+
// APIKeyAuth authenticates using a static API key passed in a request header.
87+
// SecretRef is resolved at dispatch time via SecretStore.
88+
type APIKeyAuth struct {
89+
// HeaderName is the HTTP header name to use (e.g., "X-API-Key").
90+
HeaderName string
91+
// SecretRef is the reference to the secret containing the API key value.
92+
SecretRef string
93+
}
94+
95+
// AuthType implements AuthConfig.
96+
func (a *APIKeyAuth) AuthType() string { return "api_key" }
97+
98+
// BasicAuth authenticates using HTTP Basic authentication.
99+
// Username is a plain value; PasswordRef is a secret reference resolved via SecretStore.
100+
type BasicAuth struct {
101+
// Username is the Basic auth username (not a secret).
102+
Username string
103+
// PasswordRef is the reference to the secret containing the password.
104+
PasswordRef string
105+
}
106+
107+
// AuthType implements AuthConfig.
108+
func (a *BasicAuth) AuthType() string { return "basic" }
109+
110+
// OAuth2Auth authenticates using the OAuth 2.0 client credentials flow.
111+
// ClientID is a plain value; ClientSecretRef is a secret reference resolved via SecretStore.
112+
type OAuth2Auth struct {
113+
// TokenURL is the token endpoint URL.
114+
TokenURL string
115+
// ClientID is the OAuth 2.0 client identifier (not a secret).
116+
ClientID string
117+
// ClientSecretRef is the reference to the secret containing the OAuth client secret.
118+
ClientSecretRef string
119+
// Scopes is the list of OAuth scopes to request.
120+
Scopes []string
121+
}
122+
123+
// AuthType implements AuthConfig.
124+
func (a *OAuth2Auth) AuthType() string { return "oauth2" }
125+
126+
// HMACAuth authenticates by signing request payloads with HMAC.
127+
// SecretRef is resolved at dispatch time via SecretStore.
128+
type HMACAuth struct {
129+
// SecretRef is the reference to the HMAC signing secret.
130+
SecretRef string
131+
// Algorithm is the HMAC algorithm to use (e.g., "sha256", "sha512").
132+
Algorithm string
133+
// SignatureHeader is the HTTP header where the computed signature is placed.
134+
SignatureHeader string
135+
}
136+
137+
// AuthType implements AuthConfig.
138+
func (a *HMACAuth) AuthType() string { return "hmac" }
139+
140+
// MTLSAuth authenticates using mutual TLS with a client certificate.
141+
// All three fields are secret references resolved at dispatch time via SecretStore.
142+
type MTLSAuth struct {
143+
// ClientCertRef is the reference to the secret containing the PEM-encoded client certificate.
144+
ClientCertRef string
145+
// ClientKeyRef is the reference to the secret containing the PEM-encoded client private key.
146+
ClientKeyRef string
147+
// CACertRef is an optional reference to the secret containing the CA certificate used to
148+
// verify the provider's server certificate. Empty string means the system CA pool is used.
149+
CACertRef string
150+
}
151+
152+
// AuthType implements AuthConfig.
153+
func (a *MTLSAuth) AuthType() string { return "mtls" }
154+
155+
// RetryPolicy defines how failed requests to a provider should be retried.
156+
type RetryPolicy struct {
157+
// MaxAttempts is the maximum number of request attempts (including the initial attempt).
158+
MaxAttempts int
159+
// InitialBackoff is the wait duration before the first retry.
160+
InitialBackoff time.Duration
161+
// MaxBackoff is the maximum wait duration between retries.
162+
MaxBackoff time.Duration
163+
// BackoffMultiplier is the dimensionless scaling factor applied to the backoff duration on each retry
164+
// (e.g., 2.0 doubles the backoff). This is a pure numeric multiplier, not a duration.
165+
BackoffMultiplier float64
166+
}
167+
168+
// RateLimitConfig defines the rate limiting policy for outbound requests to a provider.
169+
type RateLimitConfig struct {
170+
// RequestsPerSecond is the maximum number of requests allowed per second.
171+
RequestsPerSecond float64
172+
// BurstSize is the maximum number of requests allowed in a burst above the steady-state rate.
173+
BurstSize int
174+
}
175+
176+
// ProviderConnection is the aggregate root representing a configured connection to an
177+
// external provider. It tracks health, circuit breaker state, and authentication
178+
// configuration. Secret-valued auth config fields store references resolved at dispatch
179+
// time via the SecretStore port — raw secret values are never stored here.
180+
type ProviderConnection struct {
181+
// TenantID is the owning tenant's identifier.
182+
TenantID string
183+
184+
// ConnectionID is the unique identifier for this connection.
185+
ConnectionID string
186+
187+
// ProviderName is the human-readable name of the provider (e.g., "acme-bank").
188+
ProviderName string
189+
190+
// ProviderType is the category of provider (e.g., "bank", "energy", "compute").
191+
ProviderType string
192+
193+
// Protocol is the communication protocol used for this connection.
194+
Protocol Protocol
195+
196+
// BaseURL is the root URL for the provider's API.
197+
BaseURL string
198+
199+
// AuthConfig holds the authentication configuration for this connection.
200+
AuthConfig AuthConfig
201+
202+
// RetryPolicy defines retry behavior for failed requests.
203+
RetryPolicy RetryPolicy
204+
205+
// RateLimitConfig defines rate limiting for outbound requests.
206+
RateLimitConfig RateLimitConfig
207+
208+
// HealthStatus is the current observed health of the provider connection.
209+
HealthStatus HealthStatus
210+
211+
// LastHealthCheckAt is the time of the most recent health check, or nil if none performed.
212+
LastHealthCheckAt *time.Time
213+
214+
// CircuitState is the current state of the circuit breaker.
215+
CircuitState CircuitState
216+
217+
// CircuitOpenedAt is the time the circuit was opened, or nil when the circuit has not been tripped.
218+
CircuitOpenedAt *time.Time
219+
220+
// FailureCount is the count of consecutive failures since the last RecordSuccess call.
221+
FailureCount int
222+
223+
// SuccessCount is the total number of successes recorded on this connection.
224+
SuccessCount int
225+
226+
// CreatedAt is the time this connection was created.
227+
CreatedAt time.Time
228+
229+
// UpdatedAt is the time this connection was last modified.
230+
UpdatedAt time.Time
231+
}
232+
233+
// NewProviderConnection creates and validates a new ProviderConnection aggregate.
234+
// Returns ErrInvalidProtocol if the protocol is not one of the known values.
235+
func NewProviderConnection(
236+
tenantID string,
237+
providerName string,
238+
providerType string,
239+
protocol Protocol,
240+
baseURL string,
241+
authConfig AuthConfig,
242+
retryPolicy RetryPolicy,
243+
rateLimitConfig RateLimitConfig,
244+
) (*ProviderConnection, error) {
245+
if tenantID == "" {
246+
return nil, ErrTenantIDRequired
247+
}
248+
if providerName == "" {
249+
return nil, ErrProviderNameRequired
250+
}
251+
if baseURL == "" {
252+
return nil, ErrBaseURLRequired
253+
}
254+
if authConfig == nil {
255+
return nil, ErrAuthConfigRequired
256+
}
257+
if _, ok := validProtocols[protocol]; !ok {
258+
return nil, ErrInvalidProtocol
259+
}
260+
261+
now := time.Now().UTC()
262+
return &ProviderConnection{
263+
TenantID: tenantID,
264+
ConnectionID: uuid.New().String(),
265+
ProviderName: providerName,
266+
ProviderType: providerType,
267+
Protocol: protocol,
268+
BaseURL: baseURL,
269+
AuthConfig: authConfig,
270+
RetryPolicy: retryPolicy,
271+
RateLimitConfig: rateLimitConfig,
272+
HealthStatus: HealthStatusUnknown,
273+
CircuitState: CircuitStateClosed,
274+
FailureCount: 0,
275+
SuccessCount: 0,
276+
CreatedAt: now,
277+
UpdatedAt: now,
278+
}, nil
279+
}
280+
281+
// RecordSuccess records a successful request to the provider and increments SuccessCount.
282+
// When the circuit is closed or half-open, it also resets FailureCount and closes the circuit
283+
// (the half-open → closed transition confirms recovery).
284+
func (c *ProviderConnection) RecordSuccess() {
285+
c.SuccessCount++
286+
switch c.CircuitState {
287+
case CircuitStateHalfOpen:
288+
c.CircuitState = CircuitStateClosed
289+
c.FailureCount = 0
290+
c.CircuitOpenedAt = nil
291+
case CircuitStateClosed:
292+
c.FailureCount = 0
293+
case CircuitStateOpen:
294+
// Success during open state is unexpected (IsAvailable returns false).
295+
// Record it but do not change circuit state automatically; use AttemptReset first.
296+
}
297+
c.UpdatedAt = time.Now().UTC()
298+
}
299+
300+
// RecordFailure records a failed request to the provider and trips the circuit breaker
301+
// if the failure count reaches the given threshold. In the half-open state any failure
302+
// immediately re-trips the circuit. Returns ErrInvalidThreshold if threshold <= 0.
303+
func (c *ProviderConnection) RecordFailure(threshold int) error {
304+
if threshold <= 0 {
305+
return ErrInvalidThreshold
306+
}
307+
c.FailureCount++
308+
switch c.CircuitState {
309+
case CircuitStateClosed:
310+
if c.FailureCount >= threshold {
311+
c.TripCircuit()
312+
return nil
313+
}
314+
case CircuitStateHalfOpen:
315+
c.TripCircuit()
316+
return nil
317+
case CircuitStateOpen:
318+
// Circuit already open; failure is recorded but no additional state change needed.
319+
}
320+
c.UpdatedAt = time.Now().UTC()
321+
return nil
322+
}
323+
324+
// TripCircuit transitions the circuit breaker to the open state, blocking further requests.
325+
// If the circuit is already open, CircuitOpenedAt is preserved so that the open duration
326+
// is measured from the original trip time, not a subsequent re-evaluation.
327+
func (c *ProviderConnection) TripCircuit() {
328+
now := time.Now().UTC()
329+
c.CircuitState = CircuitStateOpen
330+
if c.CircuitOpenedAt == nil {
331+
c.CircuitOpenedAt = &now
332+
}
333+
c.UpdatedAt = now
334+
}
335+
336+
// AttemptReset transitions the circuit breaker from open to half-open, allowing a probe
337+
// request to test whether the provider has recovered. Calling AttemptReset when the
338+
// circuit is closed or already half-open is a no-op.
339+
func (c *ProviderConnection) AttemptReset() {
340+
if c.CircuitState == CircuitStateOpen {
341+
c.CircuitState = CircuitStateHalfOpen
342+
c.UpdatedAt = time.Now().UTC()
343+
}
344+
}
345+
346+
// IsAvailable returns true when the circuit breaker is in a state that permits sending
347+
// requests to the provider (closed or half-open for a probe attempt).
348+
func (c *ProviderConnection) IsAvailable() bool {
349+
return c.CircuitState == CircuitStateClosed || c.CircuitState == CircuitStateHalfOpen
350+
}
351+
352+
// UpdateHealthStatus sets the health status and records the current time as the last
353+
// health check timestamp.
354+
func (c *ProviderConnection) UpdateHealthStatus(status HealthStatus) {
355+
now := time.Now().UTC()
356+
c.HealthStatus = status
357+
c.LastHealthCheckAt = &now
358+
c.UpdatedAt = now
359+
}

0 commit comments

Comments
 (0)