go / render

Render hosts my services. Its deploy API is three endpoints, so a small client beats an SDK. cmd/deploy drives it: find the live commit, trigger a deploy, wait for it to go live.

Client

render.Client uses functional options so tests can swap 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 deploys and returns the short SHA of the live one. The list endpoint wraps each deploy in a {"deploy": {...}} envelope:

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 POSTs a commit and returns the new deploy's ID so the caller can poll it:

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 is the interesting method. It polls every 10s until the deploy reaches a terminal state, with a 30-minute timeout and 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 terminal state other than live is an error. A failed pre-deploy migration surfaces as pre_deploy_failed, which is why cmd/deploy waits for the migration service to go live before deploying the web service. The pollInterval var is overridable in tests so they don't wait 10s between polls.

Shared request path

get and post funnel into one request method that marshals the body, sets auth and JSON headers, and hands the build closure to the shared backoff helper:

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 body marshals once, outside the closure. The closure builds a fresh *http.Request per attempt, since a request body reader is consumed on read and a retry needs a new one.

← All articles