edge-connect-client/sdk/internal/http/transport.go
Waldemar 9a06c608b2 feat(sdk): Implement EdgeXR Master Controller Go SDK foundation
Phase 1 Implementation - Core SDK foundation with typed APIs:

## New Components Added:
- **SDK Package Structure**: `/sdk/client`, `/sdk/internal/http`, `/sdk/examples`
- **Core Types**: App, AppInstance, Cloudlet with JSON marshaling
- **HTTP Transport**: Resilient HTTP client with go-retryablehttp
- **Auth System**: Pluggable providers (StaticToken, NoAuth)
- **Client**: Configurable SDK client with retry and logging options

## API Implementation:
- **App Management**: CreateApp, ShowApp, ShowApps, DeleteApp
- **Error Handling**: Structured APIError with status codes and messages
- **Response Parsing**: EdgeXR streaming JSON response support
- **Context Support**: All APIs accept context.Context for timeouts/cancellation

## Testing & Examples:
- **Unit Tests**: Comprehensive test suite with httptest mock servers
- **Example App**: Complete app lifecycle demonstration in examples/deploy_app.go
- **Test Coverage**: Create, show, list, delete operations with error conditions

## Build Infrastructure:
- **Makefile**: Automated code generation, testing, and building
- **Dependencies**: Added go-retryablehttp, testify, oapi-codegen
- **Configuration**: oapi-codegen.yaml for type generation

## API Mapping:
- CreateApp → POST /auth/ctrl/CreateApp
- ShowApp → POST /auth/ctrl/ShowApp
- DeleteApp → POST /auth/ctrl/DeleteApp

Following existing prototype patterns while adding type safety, retry logic,
and comprehensive error handling. Ready for Phase 2 AppInstance APIs.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 14:05:20 +02:00

218 lines
No EOL
5.4 KiB
Go

// ABOUTME: HTTP transport layer with retry logic and request/response handling
// ABOUTME: Provides resilient HTTP communication with context support and error wrapping
package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"time"
"github.com/hashicorp/go-retryablehttp"
)
// Transport wraps HTTP operations with retry logic and error handling
type Transport struct {
client *retryablehttp.Client
authProvider AuthProvider
logger Logger
}
// AuthProvider interface for attaching authentication
type AuthProvider interface {
Attach(ctx context.Context, req *http.Request) error
}
// Logger interface for request/response logging
type Logger interface {
Printf(format string, v ...interface{})
}
// RetryOptions configures retry behavior
type RetryOptions struct {
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
Multiplier float64
RetryableHTTPStatusCodes []int
}
// NewTransport creates a new HTTP transport with retry capabilities
func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transport {
client := retryablehttp.NewClient()
// Configure retry policy
client.RetryMax = opts.MaxRetries
client.RetryWaitMin = opts.InitialDelay
client.RetryWaitMax = opts.MaxDelay
// Custom retry policy that considers both network errors and HTTP status codes
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
// Default retry for network errors
if err != nil {
return true, nil
}
// Check if status code is retryable
if resp != nil {
for _, code := range opts.RetryableHTTPStatusCodes {
if resp.StatusCode == code {
return true, nil
}
}
}
return false, nil
}
// Custom backoff with jitter
client.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
mult := math.Pow(opts.Multiplier, float64(attemptNum))
sleep := time.Duration(mult) * min
if sleep > max {
sleep = max
}
// Add jitter
jitter := time.Duration(rand.Float64() * float64(sleep) * 0.1)
return sleep + jitter
}
// Disable default logging if no logger provided
if logger == nil {
client.Logger = nil
}
return &Transport{
client: client,
authProvider: auth,
logger: logger,
}
}
// Call executes an HTTP request with retry logic and returns typed response
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
// Marshal request body if provided
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonData)
}
// Create retryable request
req, err := retryablehttp.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Add authentication
if t.authProvider != nil {
if err := t.authProvider.Attach(ctx, req.Request); err != nil {
return nil, fmt.Errorf("failed to attach auth: %w", err)
}
}
// Log request
if t.logger != nil {
t.logger.Printf("HTTP %s %s", method, url)
}
// Execute request
resp, err := t.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
// Log response
if t.logger != nil {
t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode)
}
return resp, nil
}
// CallJSON executes a request and unmarshals the response into a typed result
func (t *Transport) CallJSON(ctx context.Context, method, url string, body interface{}, result interface{}) (*http.Response, error) {
resp, err := t.Call(ctx, method, url, body)
if err != nil {
return resp, err
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return resp, fmt.Errorf("failed to read response body: %w", err)
}
// For error responses, don't try to unmarshal into result type
if resp.StatusCode >= 400 {
return resp, &HTTPError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: respBody,
}
}
// Unmarshal successful response
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return resp, fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return resp, nil
}
// HTTPError represents an HTTP error response
type HTTPError struct {
StatusCode int `json:"status_code"`
Status string `json:"status"`
Body []byte `json:"-"`
}
func (e *HTTPError) Error() string {
if len(e.Body) > 0 {
return fmt.Sprintf("HTTP %d %s: %s", e.StatusCode, e.Status, string(e.Body))
}
return fmt.Sprintf("HTTP %d %s", e.StatusCode, e.Status)
}
// IsRetryable returns true if the error indicates a retryable condition
func (e *HTTPError) IsRetryable() bool {
return e.StatusCode >= 500 || e.StatusCode == 429 || e.StatusCode == 408
}
// ParseJSONLines parses streaming JSON response line by line
func ParseJSONLines(body io.Reader, callback func([]byte) error) error {
decoder := json.NewDecoder(body)
for {
var raw json.RawMessage
if err := decoder.Decode(&raw); err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("failed to decode JSON line: %w", err)
}
if err := callback(raw); err != nil {
return err
}
}
return nil
}