570 lines
13 KiB
Markdown
570 lines
13 KiB
Markdown
# Client Application Development Recommendations
|
|
|
|
This guide provides best practices for building applications that integrate with the Notifier service.
|
|
|
|
## Architecture & Design
|
|
|
|
### 1. Credential Injection Pattern
|
|
|
|
Use dependency injection to pass the API key to your notification client:
|
|
|
|
```go
|
|
type NotificationService struct {
|
|
client *NotifierClient
|
|
apiKey string // Injected at initialization
|
|
logger Logger
|
|
}
|
|
|
|
func NewNotificationService(addr, apiKey string, logger Logger) (*NotificationService, error) {
|
|
client, err := NewNotifierClient(addr, apiKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &NotificationService{
|
|
client: client,
|
|
apiKey: apiKey,
|
|
logger: logger,
|
|
}, nil
|
|
}
|
|
```
|
|
|
|
### 2. Configuration Management
|
|
|
|
**Structure your config to externalize credentials:**
|
|
|
|
```go
|
|
type Config struct {
|
|
Notifier NotifierConfig `yaml:"notifier"`
|
|
// ...
|
|
}
|
|
|
|
type NotifierConfig struct {
|
|
Address string `yaml:"address"` // e.g., "localhost:50051"
|
|
APIKey string `yaml:"api_key"` // Load from env var
|
|
}
|
|
|
|
func (c *Config) LoadFromEnv() {
|
|
if key := os.Getenv("NOTIFIER_API_KEY"); key != "" {
|
|
c.Notifier.APIKey = key
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Rate Limiting & Retry Logic
|
|
|
|
Implement exponential backoff for rate limit errors:
|
|
|
|
```go
|
|
func (s *NotificationService) SendWithRetry(ctx context.Context, req *SendRequest) error {
|
|
var lastErr error
|
|
maxRetries := 3
|
|
baseDelay := 100 * time.Millisecond
|
|
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
err := s.Send(ctx, req)
|
|
|
|
// Check if it's a rate limit error
|
|
if err != nil && isRateLimitError(err) {
|
|
// Exponential backoff: 100ms, 200ms, 400ms
|
|
delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
|
|
time.Sleep(delay)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return err // Don't retry non-rate-limit errors
|
|
}
|
|
|
|
return nil // Success
|
|
}
|
|
|
|
return fmt.Errorf("rate limit exceeded after %d retries: %w", maxRetries, lastErr)
|
|
}
|
|
```
|
|
|
|
### 4. Error Handling Strategy
|
|
|
|
Define clear error handling for each scenario:
|
|
|
|
```go
|
|
type NotificationError struct {
|
|
Code string // "auth_failed", "rate_limited", "invalid_request", "server_error"
|
|
Message string
|
|
Retryable bool
|
|
}
|
|
|
|
func isRetryable(err error) bool {
|
|
// Retryable: rate limits, temporary network errors, 503
|
|
// Non-retryable: auth errors, validation errors, 404
|
|
// ...
|
|
}
|
|
```
|
|
|
|
## Security Best Practices
|
|
|
|
### 1. Secret Management Hierarchy
|
|
|
|
```
|
|
Priority 1: Environment Variables
|
|
Priority 2: Configuration Files (restricted permissions)
|
|
Priority 3: Secrets Manager (Vault, AWS Secrets Manager)
|
|
Priority 4: Kubernetes Secrets (if using K8s)
|
|
```
|
|
|
|
**Example:**
|
|
|
|
```bash
|
|
# Load from highest priority available
|
|
if [ -n "$NOTIFIER_API_KEY" ]; then
|
|
# Use env var
|
|
API_KEY="$NOTIFIER_API_KEY"
|
|
elif [ -f /etc/notifier-secret ]; then
|
|
# Use secret file (only readable by app user)
|
|
API_KEY=$(cat /etc/notifier-secret)
|
|
else
|
|
# Fail - no credential found
|
|
exit 1
|
|
fi
|
|
```
|
|
|
|
### 2. Key Rotation Strategy
|
|
|
|
Implement zero-downtime key rotation:
|
|
|
|
```go
|
|
type NotifierClient struct {
|
|
primaryKey string
|
|
secondaryKey string // For rotation period
|
|
}
|
|
|
|
func (c *NotifierClient) Authenticate(ctx context.Context) error {
|
|
// Try primary key first
|
|
if err := c.tryAuthenticate(ctx, c.primaryKey); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Fall back to secondary key
|
|
if err := c.tryAuthenticate(ctx, c.secondaryKey); err == nil {
|
|
return nil
|
|
}
|
|
|
|
return errors.New("authentication failed with all keys")
|
|
}
|
|
|
|
// During rotation:
|
|
// 1. Create new key
|
|
// 2. Deploy code with new key as primary
|
|
// 3. After deploy completes, disable old key in Notifier service
|
|
// 4. Remove old key from config
|
|
```
|
|
|
|
### 3. Preventing Credential Leaks
|
|
|
|
```go
|
|
// DON'T: Log credentials
|
|
logger.Infof("Using API key: %s", apiKey) // WRONG!
|
|
|
|
// DO: Log masked credentials
|
|
maskedKey := apiKey[:10] + "..." + apiKey[len(apiKey)-4:]
|
|
logger.Infof("Using API key: %s", maskedKey) // CORRECT
|
|
|
|
// DO: Implement SafeString for sensitive values
|
|
type SafeString string
|
|
|
|
func (s SafeString) String() string {
|
|
str := string(s)
|
|
if len(str) < 10 {
|
|
return "***"
|
|
}
|
|
return str[:4] + "***" + str[len(str)-4:]
|
|
}
|
|
|
|
// DO: Clear sensitive data from memory after use
|
|
func (c *NotifierClient) Close() error {
|
|
if c.apiKey != "" {
|
|
// Clear from memory (best-effort)
|
|
for i := 0; i < len(c.apiKey); i++ {
|
|
c.apiKey[i] = 0
|
|
}
|
|
}
|
|
return c.conn.Close()
|
|
}
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### 1. Connection Pooling
|
|
|
|
For gRPC:
|
|
|
|
```go
|
|
// Reuse single connection for multiple calls
|
|
conn, _ := grpc.Dial(address,
|
|
grpc.WithDefaultCallOptions(
|
|
grpc.MaxCallRecvMsgSize(4*1024*1024),
|
|
),
|
|
)
|
|
defer conn.Close()
|
|
|
|
client := pb.NewNotifierServiceClient(conn)
|
|
|
|
// Reuse for multiple calls
|
|
for _, notif := range notifications {
|
|
client.SendNotification(ctx, notif)
|
|
}
|
|
```
|
|
|
|
For REST:
|
|
|
|
```go
|
|
// Use http.Client with connection pooling
|
|
httpClient := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
MaxConnsPerHost: 100,
|
|
},
|
|
}
|
|
|
|
// Reuse for multiple requests
|
|
resp, _ := httpClient.Do(req)
|
|
```
|
|
|
|
### 2. Batch Operations
|
|
|
|
Group notifications to reduce API calls:
|
|
|
|
```go
|
|
type BatchNotifier struct {
|
|
client *NotifierClient
|
|
batchSize int
|
|
ticker *time.Ticker
|
|
queue []*SendRequest
|
|
}
|
|
|
|
func (bn *BatchNotifier) Queue(req *SendRequest) {
|
|
bn.queue = append(bn.queue, req)
|
|
|
|
// Flush when batch is full
|
|
if len(bn.queue) >= bn.batchSize {
|
|
bn.Flush()
|
|
}
|
|
}
|
|
|
|
func (bn *BatchNotifier) Flush() {
|
|
if len(bn.queue) == 0 {
|
|
return
|
|
}
|
|
|
|
// Send batch
|
|
bn.client.SendBatch(context.Background(), bn.queue)
|
|
bn.queue = nil
|
|
}
|
|
```
|
|
|
|
### 3. Caching & Memoization
|
|
|
|
Cache notifier metadata to reduce API calls:
|
|
|
|
```go
|
|
type CachedNotifierClient struct {
|
|
client *NotifierClient
|
|
notifiersMu sync.RWMutex
|
|
notifiers *pb.NotifiersResponse
|
|
notifiersAge time.Time
|
|
cacheTTL time.Duration
|
|
}
|
|
|
|
func (cnc *CachedNotifierClient) GetNotifiers(ctx context.Context) (*pb.NotifiersResponse, error) {
|
|
cnc.notifiersMu.RLock()
|
|
if time.Since(cnc.notifiersAge) < cnc.cacheTTL && cnc.notifiers != nil {
|
|
defer cnc.notifiersMu.RUnlock()
|
|
return cnc.notifiers, nil
|
|
}
|
|
cnc.notifiersMu.RUnlock()
|
|
|
|
// Fetch from server
|
|
notifiers, err := cnc.client.GetNotifiers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache result
|
|
cnc.notifiersMu.Lock()
|
|
cnc.notifiers = notifiers
|
|
cnc.notifiersAge = time.Now()
|
|
cnc.notifiersMu.Unlock()
|
|
|
|
return notifiers, nil
|
|
}
|
|
```
|
|
|
|
## Monitoring & Observability
|
|
|
|
### 1. Instrumentation
|
|
|
|
Instrument your notification client:
|
|
|
|
```go
|
|
import "go.opentelemetry.io/otel"
|
|
|
|
type InstrumentedNotifierClient struct {
|
|
client *NotifierClient
|
|
tracer trace.Tracer
|
|
}
|
|
|
|
func (inc *InstrumentedNotifierClient) SendNotification(ctx context.Context, req *SendRequest) error {
|
|
ctx, span := inc.tracer.Start(ctx, "send_notification")
|
|
defer span.End()
|
|
|
|
span.SetAttributes(
|
|
attribute.String("notification.type", string(req.Type)),
|
|
attribute.Int("notification.recipients", len(req.Recipients)),
|
|
)
|
|
|
|
err := inc.client.SendNotification(ctx, req)
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
}
|
|
|
|
return err
|
|
}
|
|
```
|
|
|
|
### 2. Metrics Collection
|
|
|
|
Track key metrics:
|
|
|
|
```go
|
|
type MetricsCollector struct {
|
|
sendAttempts prometheus.Counter
|
|
sendSuccesses prometheus.Counter
|
|
sendFailures prometheus.Counter
|
|
sendDuration prometheus.Histogram
|
|
rateLimitErrors prometheus.Counter
|
|
}
|
|
|
|
func (mc *MetricsCollector) Record(result *SendResult) {
|
|
mc.sendAttempts.Inc()
|
|
|
|
if result.Error != nil {
|
|
mc.sendFailures.Inc()
|
|
if isRateLimitError(result.Error) {
|
|
mc.rateLimitErrors.Inc()
|
|
}
|
|
} else {
|
|
mc.sendSuccesses.Inc()
|
|
}
|
|
|
|
mc.sendDuration.Observe(result.Duration.Seconds())
|
|
}
|
|
```
|
|
|
|
### 3. Health Checks
|
|
|
|
Periodically verify connectivity:
|
|
|
|
```go
|
|
func (s *NotificationService) HealthCheck(ctx context.Context) error {
|
|
deadline, _ := context.WithTimeout(ctx, 5*time.Second)
|
|
_, err := s.client.HealthCheck(deadline)
|
|
return err
|
|
}
|
|
|
|
// In your main loop
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
go func() {
|
|
for range ticker.C {
|
|
if err := s.HealthCheck(context.Background()); err != nil {
|
|
logger.Errorf("Health check failed: %v", err)
|
|
// Maybe trigger alerts or circuit breaker
|
|
}
|
|
}
|
|
}()
|
|
```
|
|
|
|
## Testing
|
|
|
|
### 1. Mock the Notifier Client
|
|
|
|
```go
|
|
type MockNotifierClient struct {
|
|
SendNotificationFunc func(context.Context, *SendRequest) error
|
|
}
|
|
|
|
func (m *MockNotifierClient) SendNotification(ctx context.Context, req *SendRequest) error {
|
|
if m.SendNotificationFunc != nil {
|
|
return m.SendNotificationFunc(ctx, req)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// In tests
|
|
func TestNotificationService(t *testing.T) {
|
|
mock := &MockNotifierClient{
|
|
SendNotificationFunc: func(ctx context.Context, req *SendRequest) error {
|
|
assert.Equal(t, "email", string(req.Type))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
svc := NewNotificationService(mock)
|
|
err := svc.Notify("test@example.com", "Hello")
|
|
assert.NoError(t, err)
|
|
}
|
|
```
|
|
|
|
### 2. Test Rate Limiting
|
|
|
|
```go
|
|
func TestRateLimitHandling(t *testing.T) {
|
|
responses := []error{
|
|
status.Error(codes.ResourceExhausted, "rate limit"),
|
|
status.Error(codes.ResourceExhausted, "rate limit"),
|
|
nil, // Success on third try
|
|
}
|
|
|
|
callCount := 0
|
|
mock := &MockNotifierClient{
|
|
SendNotificationFunc: func(ctx context.Context, req *SendRequest) error {
|
|
err := responses[callCount]
|
|
callCount++
|
|
return err
|
|
},
|
|
}
|
|
|
|
svc := NewNotificationService(mock)
|
|
err := svc.SendWithRetry(context.Background(), &SendRequest{...})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 3, callCount)
|
|
}
|
|
```
|
|
|
|
## Deployment Considerations
|
|
|
|
### 1. Environment Variables Checklist
|
|
|
|
```bash
|
|
# Production checklist
|
|
NOTIFIER_API_KEY=nk_... # From secure secrets manager
|
|
NOTIFIER_ADDRESS=notifier:50051 # Use internal DNS
|
|
NOTIFIER_TIMEOUT=30s # Reasonable timeout
|
|
APP_LOG_LEVEL=info # Not debug (sensitive logs)
|
|
```
|
|
|
|
### 2. Kubernetes Secrets
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: notifier-credentials
|
|
type: Opaque
|
|
stringData:
|
|
api-key: nk_...
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: my-app
|
|
spec:
|
|
template:
|
|
spec:
|
|
containers:
|
|
- name: my-app
|
|
env:
|
|
- name: NOTIFIER_API_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: notifier-credentials
|
|
key: api-key
|
|
- name: NOTIFIER_ADDRESS
|
|
value: notifier:50051
|
|
```
|
|
|
|
### 3. Docker Best Practices
|
|
|
|
```dockerfile
|
|
# DON'T embed credentials
|
|
ARG API_KEY=default
|
|
ENV NOTIFIER_API_KEY=$API_KEY
|
|
|
|
# DO mount secrets
|
|
# docker run -v /run/secrets/notifier_api_key:/etc/notifier-secret ...
|
|
|
|
# DO use multi-stage builds to exclude dev dependencies
|
|
FROM golang:1.21-alpine AS builder
|
|
WORKDIR /build
|
|
COPY . .
|
|
RUN go build -o app .
|
|
|
|
FROM alpine:latest
|
|
COPY --from=builder /build/app .
|
|
# Credentials provided at runtime only
|
|
CMD ["./app"]
|
|
```
|
|
|
|
## Versioning & Compatibility
|
|
|
|
### 1. API Versioning
|
|
|
|
Your client should handle API changes gracefully:
|
|
|
|
```go
|
|
type APIVersion struct {
|
|
Major int
|
|
Minor int
|
|
Patch int
|
|
}
|
|
|
|
func (s *NotificationService) CheckCompatibility(version APIVersion) error {
|
|
if version.Major != 1 {
|
|
return fmt.Errorf("incompatible API version: %d", version.Major)
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### 2. Feature Detection
|
|
|
|
Detect available features instead of hardcoding versions:
|
|
|
|
```go
|
|
func (s *NotificationService) SupportsHTMLEmail() bool {
|
|
notifiers, _ := s.GetNotifiers(context.Background())
|
|
for _, n := range notifiers.Notifiers {
|
|
if n.Type == TypeEmail {
|
|
return true // Assume HTML support in email notifiers
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
```
|
|
|
|
## Troubleshooting Checklist
|
|
|
|
- [ ] API key format is correct: `nk_<32-hex>`
|
|
- [ ] API key hasn't expired
|
|
- [ ] Client has required roles for the notifier
|
|
- [ ] Rate limit hasn't been exceeded
|
|
- [ ] Notifier service is accessible (network, firewall)
|
|
- [ ] Request payload is valid JSON/protobuf
|
|
- [ ] Notifier account exists in service config
|
|
- [ ] Credentials are being loaded from environment (not hardcoded)
|
|
- [ ] Connection is using correct protocol (HTTP/2 for gRPC)
|
|
- [ ] Logs are not leaking sensitive data
|
|
|
|
## Summary
|
|
|
|
1. **Externalize credentials** - Use env vars or secrets managers
|
|
2. **Implement retries** - Handle rate limits gracefully
|
|
3. **Cache when possible** - Reduce API calls
|
|
4. **Monitor health** - Regular health checks
|
|
5. **Instrument code** - Add tracing and metrics
|
|
6. **Test thoroughly** - Mock clients and test error cases
|
|
7. **Secure deployment** - Mount secrets at runtime, not build time
|
|
8. **Log carefully** - Never log API keys or sensitive data
|