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.