Skip to main content

Overview

The Caller service is a Go-based microservice that acts as an HTTP client proxy. It receives gRPC requests with URLs and makes HTTP GET calls to those URLs, returning status codes and response sizes. This service demonstrates the asynchronous database pattern where logging happens after the response is sent.
Source code: services/internal/caller/service.goEntry point: services/cmd/caller/main.go

Technology Stack

  • Language: Go
  • Framework: Connect-Go (gRPC-compatible)
  • Database: PostgreSQL via pgx/v5
  • Protocol: Protocol Buffers
  • Observability: OpenTelemetry
  • HTTP Version: HTTP/2 (h2c)

Configuration

Environment Variables

PORT
string
default:"8081"
HTTP server port for the caller service
DATABASE_URL
string
required
PostgreSQL connection stringExample: postgresql://devuser:devpass@postgres:5432/caller_db
OTEL_EXPORTER_OTLP_ENDPOINT
string
OpenTelemetry collector endpoint
OTEL_SERVICE_NAME
string
default:"caller-service"
Service name for distributed tracing

Docker Compose Configuration

caller:
  build:
    context: .
    dockerfile: deploy/docker/caller/Dockerfile
  environment:
    PORT: "8081"
    DATABASE_URL: "postgresql://devuser:devpass@postgres:5432/caller_db"
    OTEL_SERVICE_NAME: "caller-service"
  networks:
    - app
  depends_on:
    - postgres

API Reference

Protocol Buffer Definition

The service is defined in proto/caller/v1/caller.proto:
syntax = "proto3";

package caller.v1;

service CallerService {
  rpc CallExternal(CallExternalRequest) returns (CallExternalResponse) {}
}

message CallExternalRequest {
  string url = 1;
}

message CallExternalResponse {
  int32 status_code = 1;
  int32 body_length = 2;
}

CallExternal RPC

Makes an HTTP GET request to the specified URL and returns metadata about the response.
url
string
required
Valid HTTP/HTTPS URL to callMust be a valid URI that can be parsed by Go’s url.ParseRequestURI()
status_code
int32
HTTP status code from the external URLExamples: 200, 404, 500
body_length
int32
Size of the response body in bytesBody content is discarded after reading to save memory

Example Request

# Using grpcurl
grpcurl -plaintext -d '{"url": "https://httpbin.org/get"}' \
  localhost:8081 \
  caller.v1.CallerService/CallExternal

Example Response

{
  "status_code": 200,
  "body_length": 384
}

Implementation Details

Service Structure

From services/internal/caller/service.go:19-27:
type Service struct {
    httpClient *http.Client
    timeout    time.Duration
    pool       *pgxpool.Pool
}

func NewService(
    httpClient *http.Client, 
    timeout time.Duration, 
    pool *pgxpool.Pool,
) *Service {
    return &Service{
        httpClient: httpClient, 
        timeout:    timeout, 
        pool:       pool,
    }
}

Request Flow

  1. Validate URL: Parse and validate the URL format
  2. Create HTTP Request: Build GET request with context
  3. Execute Request: Make HTTP call with timeout
  4. Read Response: Discard body while counting bytes
  5. Return Response: Send status and size immediately
  6. Async Database Write: Log call in background goroutine
From services/internal/caller/service.go:29-76:
func (s *Service) CallExternal(
    ctx context.Context, 
    req *connect.Request[callerv1.CallExternalRequest],
) (*connect.Response[callerv1.CallExternalResponse], error) {
    targetURL := req.Msg.GetUrl()
    if _, err := url.ParseRequestURI(targetURL); err != nil {
        return nil, connect.NewError(connect.CodeInvalidArgument, 
            fmt.Errorf("invalid URL: %w", err))
    }

    rpcCtx, cancel := context.WithTimeout(ctx, s.timeout)
    defer cancel()

    httpReq, err := http.NewRequestWithContext(rpcCtx, http.MethodGet, targetURL, nil)
    if err != nil {
        return nil, connect.NewError(connect.CodeInternal, 
            fmt.Errorf("build request: %w", err))
    }

    httpResp, err := s.httpClient.Do(httpReq)
    if err != nil {
        if rpcCtx.Err() != nil {
            return nil, connect.NewError(connect.CodeDeadlineExceeded, rpcCtx.Err())
        }
        return nil, connect.NewError(connect.CodeUnavailable, 
            fmt.Errorf("call failed: %w", err))
    }
    defer httpResp.Body.Close()

    n, err := io.Copy(io.Discard, httpResp.Body)
    if err != nil {
        return nil, connect.NewError(connect.CodeInternal, 
            fmt.Errorf("read response: %w", err))
    }

    statusCode := int32(httpResp.StatusCode)
    bodyLength := int32(n)

    // Asynchronous DB write pattern
    if s.pool != nil {
        capturedURL := targetURL
        go func() {
            _, err := s.pool.Exec(context.Background(), 
                "INSERT INTO call_logs (url, status_code, body_length) VALUES ($1, $2, $3)", 
                capturedURL, statusCode, bodyLength)
            if err != nil {
                slog.Error("failed to insert call log", "error", err)
            }
        }()
    }

    resp := connect.NewResponse(&callerv1.CallExternalResponse{
        StatusCode: statusCode,
        BodyLength: bodyLength,
    })
    return resp, nil
}

Timeout Configuration

  • RPC Timeout: 2 seconds for external HTTP calls
  • HTTP Client Timeout: 2 seconds (configured in main.go)
  • Server Read Timeout: 5 seconds
  • Server Write Timeout: 30 seconds

Database Schema

The service uses a call_logs table to record all external API calls:
CREATE TABLE call_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    url TEXT NOT NULL,
    status_code INT NOT NULL,
    body_length INT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Asynchronous Database Pattern

The Caller service uses an asynchronous write pattern that differs from the Greeter service:
if s.pool != nil {
    capturedURL := targetURL
    go func() {
        _, err := s.pool.Exec(context.Background(), 
            "INSERT INTO call_logs (url, status_code, body_length) VALUES ($1, $2, $3)", 
            capturedURL, statusCode, bodyLength)
        if err != nil {
            slog.Error("failed to insert call log", "error", err)
        }
    }()
}
Benefits:
  • Lower latency - response returns immediately
  • No database backpressure affecting clients
  • Better throughput under high load
Tradeoffs:
  • Possible log loss if service crashes before goroutine completes
  • No guarantee of data persistence
  • Errors are only logged, not returned to client
Database writes happen in a separate goroutine using context.Background() to avoid cancellation. If the service crashes between the response and the database write, logs may be lost.

Error Handling

The service handles multiple error scenarios:

Invalid URL

if _, err := url.ParseRequestURI(targetURL); err != nil {
    return nil, connect.NewError(connect.CodeInvalidArgument, 
        fmt.Errorf("invalid URL: %w", err))
}
Returns: Code.INVALID_ARGUMENT

Timeout Errors

if rpcCtx.Err() != nil {
    return nil, connect.NewError(connect.CodeDeadlineExceeded, rpcCtx.Err())
}
Returns: Code.DEADLINE_EXCEEDED

Network Errors

return nil, connect.NewError(connect.CodeUnavailable, 
    fmt.Errorf("call failed: %w", err))
Returns: Code.UNAVAILABLE

Response Read Errors

if err != nil {
    return nil, connect.NewError(connect.CodeInternal, 
        fmt.Errorf("read response: %w", err))
}
Returns: Code.INTERNAL

Service Dependencies

Upstream Dependencies

  • PostgreSQL: Optional, service runs without DB but won’t persist logs
  • External APIs: Any URL provided by the caller

Downstream Consumers

  • Greeter Service: Primary consumer for external API calls
  • Other Internal Services: Any service needing HTTP proxy functionality

Observability

Structured Logging

The service uses Go’s slog package:
logger.InfoContext(ctx, "starting caller service", "port", port)
slog.Error("failed to insert call log", "error", err)

Distributed Tracing

OpenTelemetry instrumentation captures:
  • RPC call spans
  • HTTP client requests
  • Database operations (in background goroutines)
  • Error tracking

Health Checks

Health endpoint at /healthz performs database ping:
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    if dbPool != nil {
        if err := dbPool.Ping(r.Context()); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            _, _ = w.Write([]byte("db unhealthy\n"))
            return
        }
    }
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte("ok\n"))
})

Testing

Test the service using grpcurl:
# Call httpbin
grpcurl -plaintext -d '{"url": "https://httpbin.org/get"}' \
  localhost:8081 \
  caller.v1.CallerService/CallExternal

# Test 404 response
grpcurl -plaintext -d '{"url": "https://httpbin.org/status/404"}' \
  localhost:8081 \
  caller.v1.CallerService/CallExternal

# Test invalid URL
grpcurl -plaintext -d '{"url": "not-a-valid-url"}' \
  localhost:8081 \
  caller.v1.CallerService/CallExternal

# Health check
curl http://localhost:8081/healthz

Performance Characteristics

  • Latency: ~2-100ms (depends on external API response time)
  • Throughput: High - async DB writes don’t block responses
  • Database Write: Asynchronous, zero latency impact on responses
  • Timeout Budget: 2 seconds total
  • Memory Usage: Low - response bodies are discarded via io.Copy(io.Discard, ...)

Security Considerations

URL Validation

The service validates URLs before making requests:
if _, err := url.ParseRequestURI(targetURL); err != nil {
    return nil, connect.NewError(connect.CodeInvalidArgument, 
        fmt.Errorf("invalid URL: %w", err))
}

SSRF Protection

The service does not currently implement SSRF (Server-Side Request Forgery) protection. In production, you should:
  • Block requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Block requests to localhost/127.0.0.1
  • Maintain an allowlist of permitted domains
  • Implement request throttling per URL

Response Size Limits

Consider implementing maximum response size limits:
// Example: limit to 10MB
maxBytes := int64(10 * 1024 * 1024)
limitedReader := io.LimitReader(httpResp.Body, maxBytes)
n, err := io.Copy(io.Discard, limitedReader)

Common Issues

Timeout Errors

Error: Code.DEADLINE_EXCEEDED Cause: External API taking >2 seconds to respond Solution: Increase timeout or investigate external API performance

Database Connection Failed

Log: database unavailable, running without DB Impact: Service runs but doesn’t persist call logs Solution: Check DATABASE_URL and ensure PostgreSQL is accessible

Missing Call Logs

Symptom: Successful calls not appearing in database Cause: Asynchronous write failure or service shutdown before goroutine completes Solution: Check error logs for "failed to insert call log" messages

Comparison: Sync vs Async Patterns

AspectCaller (Async)Greeter (Sync)
Latency ImpactNone+1-5ms
Data GuaranteeFire-and-forgetGuaranteed before response
Error HandlingLogged onlyCan fail the request
BackpressureNo DB backpressureDB issues affect clients
Best ForHigh-throughput loggingCritical data persistence

Greeter Service

Primary consumer of Caller service

Services Overview

Learn about async vs sync patterns

Build docs developers (and LLMs) love