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.