go / render
Render hosts my services. cmd/deploy calls three endpoints of its API through a small HTTP client, not an SDK.
Client
render.Client takes functional options, so a test sets 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 returns the short SHA of the live deploy. The API
wraps each deploy 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 starts a deploy of a commit and returns its ID:
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 every 10 seconds, with a 30-minute timeout:
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):
}
}
}
A failed migration returns pre_deploy_failed, so cmd/deploy waits
for migrations before it deploys web services. A test overrides
pollInterval.
Shared request path
get and post call one request method, which retries through the
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 closure builds a new *http.Request for each attempt, because a
request drains its body reader.