go / render

Render hosts my services. Its deployment API has three endpoints, so a small HTTP client works better than a full SDK. The cmd/deploy tool uses this API to trigger deploys and monitor their progress.

Client

render.Client uses functional options so tests can configure the base URL and HTTP client:

const defaultBase = "https://api.render.com/v1"

type Client struct {
	apiKey     string
	base       string
	httpClient *http.Client
}

type Option func(*Client)

func WithHTTPClient(c *http.Client) Option {
	return func(cl *Client) { cl.httpClient = c }
}

func WithBaseURL(base string) Option {
	return func(cl *Client) { cl.base = base }
}

func NewClient(apiKey string, opts ...Option) *Client {
	c := &Client{
		apiKey:     apiKey,
		base:       defaultBase,
		httpClient: &http.Client{Timeout: 20 * time.Second},
	}
	for _, o := range opts {
		o(c)
	}
	return c
}

Three endpoints

LiveGitCommit lists recent deployments and returns the short SHA of the active commit. The API wraps each deployment in a {"deploy": {...}} object:

type deploy struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Commit struct {
		ID string `json:"id"`
	} `json:"commit"`
}

type deployListItem struct {
	Deploy deploy `json:"deploy"`
}

func (c *Client) LiveGitCommit(ctx context.Context, serviceID string) (string, error) {
	path := fmt.Sprintf("/services/%s/deploys?limit=5", serviceID)
	var deploys []deployListItem
	if err := c.get(ctx, path, &deploys); err != nil {
		return "", err
	}
	for _, d := range deploys {
		if d.Deploy.Status == "live" {
			id := d.Deploy.Commit.ID
			if len(id) > 9 {
				id = id[:9]
			}
			return id, nil
		}
	}
	return "", fmt.Errorf("no live deploy for %s", serviceID)
}

Deploy creates a new deployment for a commit SHA and returns the deployment ID for status polling:

func (c *Client) Deploy(ctx context.Context, serviceID, commitID string) (string, error) {
	path := fmt.Sprintf("/services/%s/deploys", serviceID)
	body := map[string]string{"commitId": commitID}
	var d deploy
	if err := c.post(ctx, path, body, &d); err != nil {
		return "", err
	}
	return d.ID, nil
}

Polling to a terminal state

WaitForDeploy polls the API every 10 seconds until the deployment finishes. It enforces a 30-minute timeout and supports context cancellation:

func (c *Client) WaitForDeploy(ctx context.Context, serviceID, deployID string) error {
	path := fmt.Sprintf("/services/%s/deploys/%s", serviceID, deployID)
	deadline := time.Now().Add(30 * time.Minute)
	for {
		var d deploy
		if err := c.get(ctx, path, &d); err != nil {
			return err
		}
		switch d.Status {
		case "live":
			return nil
		case "build_failed", "update_failed", "pre_deploy_failed",
			"deactivated", "canceled":
			return fmt.Errorf("deploy %s ended in status %q", deployID, d.Status)
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("timeout waiting for deploy %s; last status %q", deployID, d.Status)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(pollInterval):
		}
	}
}

Any status other than live indicates a deployment failure. For example, a failed migration returns pre_deploy_failed. Because of this, cmd/deploy waits for database migrations to complete before deploying web services. Tests can override pollInterval to avoid waiting 10 seconds between polls.

Shared request path

get and post call a shared request method. This method serializes the request body, sets authorization headers, and uses the backoff helper for retries:

func (c *Client) request(ctx context.Context, method, path string, body, result any) error {
	url := c.base + path

	var bodyBytes []byte
	if body != nil {
		var err error
		bodyBytes, err = json.Marshal(body)
		if err != nil {
			return fmt.Errorf("marshal request: %w", err)
		}
	}

	build := func() (*http.Request, error) {
		var reader io.Reader
		if bodyBytes != nil {
			reader = bytes.NewReader(bodyBytes)
		}
		req, err := http.NewRequestWithContext(ctx, method, url, reader)
		if err != nil {
			return nil, err
		}
		req.Header.Set("Accept", "application/json")
		req.Header.Set("Authorization", "Bearer "+c.apiKey)
		req.Header.Set("Content-Type", "application/json")
		return req, nil
	}

	res, err := httputil.Do(ctx, build, httputil.Config{Client: c.httpClient})
	if err != nil {
		return fmt.Errorf("render: %w", err)
	}
	if res.StatusCode/100 != 2 {
		return fmt.Errorf("render: HTTP %d: %s", res.StatusCode, res.Body)
	}
	if result != nil {
		if err := json.Unmarshal(res.Body, result); err != nil {
			return fmt.Errorf("parse response: %w", err)
		}
	}
	return nil
}

The method serializes the request body once. The closure creates a new *http.Request on each attempt, because reading a request body drains the reader.

← All articles