mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 22:52:29 +02:00
273 lines
8.4 KiB
Go
273 lines
8.4 KiB
Go
// Package deploy provides an HTTP client for the LangGraph host backend
|
|
// deployment service.
|
|
package deploy
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Secret represents a name/value pair sent as a deployment secret.
|
|
type Secret struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// HostBackendClient is a minimal JSON HTTP client for the host backend
|
|
// deployment service.
|
|
type HostBackendClient struct {
|
|
BaseURL string
|
|
APIKey string
|
|
TenantID string
|
|
client *http.Client
|
|
}
|
|
|
|
// retryTransport wraps an http.RoundTripper and retries failed requests.
|
|
type retryTransport struct {
|
|
base http.RoundTripper
|
|
retries int
|
|
}
|
|
|
|
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
var resp *http.Response
|
|
var err error
|
|
|
|
// We need to buffer the body so we can replay it on retries.
|
|
var bodyBytes []byte
|
|
if req.Body != nil {
|
|
bodyBytes, err = io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Body.Close()
|
|
}
|
|
|
|
for attempt := 0; attempt <= t.retries; attempt++ {
|
|
if bodyBytes != nil {
|
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
}
|
|
resp, err = t.base.RoundTrip(req)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
// Only retry on transport-level errors; do not retry on HTTP error
|
|
// status codes (the caller handles those).
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
// NewClient creates a new HostBackendClient. The baseURL is stripped of any
|
|
// trailing slash. The underlying http.Client uses a 30-second timeout and
|
|
// retries transport-level failures up to 3 times.
|
|
func NewClient(baseURL, apiKey string) *HostBackendClient {
|
|
return &HostBackendClient{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
APIKey: apiKey,
|
|
TenantID: "",
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &retryTransport{
|
|
base: http.DefaultTransport,
|
|
retries: 3,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// request executes an HTTP request against the host backend and returns the
|
|
// parsed JSON response. It attaches required headers and handles errors.
|
|
func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) {
|
|
fullURL := c.BaseURL + path
|
|
|
|
// Append query parameters.
|
|
if len(params) > 0 {
|
|
q := url.Values{}
|
|
for k, v := range params {
|
|
q.Set(k, v)
|
|
}
|
|
fullURL += "?" + q.Encode()
|
|
}
|
|
|
|
var body io.Reader
|
|
if payload != nil {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshalling request payload: %w", err)
|
|
}
|
|
body = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, fullURL, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("X-Api-Key", c.APIKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if c.TenantID != "" {
|
|
req.Header.Set("X-Tenant-ID", c.TenantID)
|
|
}
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response from %s: %w", path, err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
detail := string(respBody)
|
|
if detail == "" {
|
|
detail = fmt.Sprintf("%d", resp.StatusCode)
|
|
}
|
|
return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail)
|
|
}
|
|
|
|
if len(respBody) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response from %s: %w", path, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// requestNoBody is a convenience wrapper for requests that return no parsed body.
|
|
func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error {
|
|
_, err := c.request(method, path, payload, params)
|
|
return err
|
|
}
|
|
|
|
// CreateDeployment creates a new deployment.
|
|
func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) {
|
|
sourceRevisionConfig := map[string]any{}
|
|
if source == "internal_source" && configPath != "" {
|
|
sourceRevisionConfig["langgraph_config_path"] = configPath
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"name": name,
|
|
"source": source,
|
|
"source_config": map[string]any{"deployment_type": deploymentType},
|
|
"source_revision_config": sourceRevisionConfig,
|
|
}
|
|
if secrets != nil {
|
|
payload["secrets"] = secrets
|
|
}
|
|
return c.request("POST", "/v2/deployments", payload, nil)
|
|
}
|
|
|
|
// ListDeployments lists deployments, optionally filtering by name.
|
|
func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) {
|
|
params := map[string]string{"name_contains": nameContains}
|
|
return c.request("GET", "/v2/deployments", nil, params)
|
|
}
|
|
|
|
// GetDeployment retrieves a single deployment by ID.
|
|
func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) {
|
|
return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
|
|
}
|
|
|
|
// DeleteDeployment deletes a deployment by ID.
|
|
func (c *HostBackendClient) DeleteDeployment(deploymentID string) error {
|
|
return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
|
|
}
|
|
|
|
// RequestPushToken requests a push token for a deployment.
|
|
func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) {
|
|
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil)
|
|
}
|
|
|
|
// RequestUploadURL gets a signed URL for uploading the source tarball.
|
|
func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) {
|
|
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil)
|
|
}
|
|
|
|
// UpdateDeployment triggers a new revision using a pre-pushed Docker image.
|
|
func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) {
|
|
payload := map[string]any{
|
|
"revision_source": "internal_docker",
|
|
"source_revision_config": map[string]any{"image_uri": imageURI},
|
|
}
|
|
if secrets != nil {
|
|
payload["secrets"] = secrets
|
|
}
|
|
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
|
|
}
|
|
|
|
// UpdateDeploymentInternalSource triggers a remote-build revision using an
|
|
// uploaded source tarball.
|
|
func (c *HostBackendClient) UpdateDeploymentInternalSource(
|
|
deploymentID, sourceTarballPath, configPath string,
|
|
secrets []Secret,
|
|
installCommand, buildCommand string,
|
|
) (map[string]any, error) {
|
|
payload := map[string]any{
|
|
"revision_source": "internal_source",
|
|
"source_revision_config": map[string]any{
|
|
"source_tarball_path": sourceTarballPath,
|
|
"langgraph_config_path": configPath,
|
|
},
|
|
}
|
|
|
|
sourceConfig := map[string]any{}
|
|
if installCommand != "" {
|
|
sourceConfig["install_command"] = installCommand
|
|
}
|
|
if buildCommand != "" {
|
|
sourceConfig["build_command"] = buildCommand
|
|
}
|
|
if len(sourceConfig) > 0 {
|
|
payload["source_config"] = sourceConfig
|
|
}
|
|
|
|
if secrets != nil {
|
|
payload["secrets"] = secrets
|
|
}
|
|
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
|
|
}
|
|
|
|
// ListRevisions lists revisions for a deployment.
|
|
func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) {
|
|
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{
|
|
"limit": fmt.Sprintf("%d", limit),
|
|
})
|
|
}
|
|
|
|
// GetRevision retrieves a single revision.
|
|
func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) {
|
|
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil)
|
|
}
|
|
|
|
// GetBuildLogs retrieves build logs for a revision.
|
|
func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) {
|
|
return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil)
|
|
}
|
|
|
|
// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is
|
|
// included in the path to scope the logs.
|
|
func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) {
|
|
var path string
|
|
if revisionID != "" {
|
|
path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID)
|
|
} else {
|
|
path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID)
|
|
}
|
|
return c.request("POST", path, payload, nil)
|
|
}
|