feat: deliver authenticated task APIs, SDK and P1 regression

This commit is contained in:
2026-09-17 07:41:32 +08:00
parent 4f2935e36f
commit 9701b7a071
37 changed files with 3169 additions and 67 deletions

View File

@@ -12,12 +12,15 @@ import (
"time"
)
// Options currently configures bootstrap health calls. Task, Worker and workflow
// clients will be added alongside their corresponding server endpoints.
type Options struct{ HTTPClient *http.Client }
// Options 配置传输与命名空间凭据;健康查询不使用凭据。
type Options struct {
HTTPClient *http.Client
Token string
}
type Client struct {
baseURL string
http *http.Client
token string
}
type Health struct {
Status string `json:"status"`
@@ -34,6 +37,9 @@ func NewClient(baseURL string, opts Options) (*Client, error) {
if err != nil || u == nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("Orbit base URL must be absolute HTTP(S), without credentials, query or fragment")
}
if len(opts.Token) > 1024 || strings.ContainsAny(opts.Token, " \t\r\n") {
return nil, errors.New("invalid Orbit token")
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
@@ -41,7 +47,7 @@ func NewClient(baseURL string, opts Options) (*Client, error) {
clone := *client
// Do not silently forward requests to another origin through redirects.
clone.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &Client{baseURL: strings.TrimRight(u.String(), "/"), http: &clone}, nil
return &Client{baseURL: strings.TrimRight(u.String(), "/"), http: &clone, token: opts.Token}, nil
}
func (c *Client) Liveness(ctx context.Context) (Health, error) { return c.health(ctx, "/health/live") }
func (c *Client) Readiness(ctx context.Context) (Health, error) {

View File

@@ -0,0 +1,87 @@
// tasks 演示受理、重复提交、查询、分页与取消;此示例不启动业务 Worker。
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read private credentials: %w", err)
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(raw, &credentials); err != nil {
return fmt.Errorf("invalid credentials file")
}
if token == "" {
token = credentials.Token
}
if queue == "" {
queue = credentials.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
if queue == "" {
return fmt.Errorf("ORBIT_QUEUE_ID is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var operation [16]byte
if _, err := rand.Read(operation[:]); err != nil {
return err
}
key := "example-" + hex.EncodeToString(operation[:])
// 生产接入应先在业务库保存稳定键;本示例的两次请求共用同一个键。
in := orbit.SubmitTaskRequest{QueueID: queue, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"source_ref":"example/input.txt","format":"pdf","sequence":9007199254740993}`)}
first, err := client.SubmitTask(ctx, key, in)
if err != nil {
return err
}
again, err := client.SubmitTask(ctx, key, in)
if err != nil {
return err
}
if first.Task.ID != again.Task.ID || !again.Deduplicated {
return fmt.Errorf("duplicate submission did not return the original task")
}
current, err := client.GetTask(ctx, first.Task.ID)
if err != nil {
return err
}
page, err := client.ListTasks(ctx, orbit.ListTasksRequest{QueueID: queue, Limit: 10})
if err != nil {
return err
}
cancelled, err := client.CancelTask(ctx, current.ID, key+"-cancel", orbit.CancelTaskRequest{Reason: "finish task acceptance example", ExpectedRevision: current.Revision})
if err != nil {
return err
}
fmt.Printf("task=%s duplicate=%t listed=%d final_state=%s revision=%d\n", current.ID, again.Deduplicated, len(page.Items), cancelled.Task.State, cancelled.Task.Revision)
if cancelled.Task.State != orbit.TaskCancelled {
return fmt.Errorf("expected CANCELLED")
}
return nil
}

161
sdk/go/tasks.go Normal file
View File

@@ -0,0 +1,161 @@
package orbit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)
// APIError 提供可分支处理的错误码和请求追踪标识,不包含内部数据库错误。
type APIError struct {
StatusCode int
Code string
Message string
RequestID string
}
func (e *APIError) Error() string {
return fmt.Sprintf("orbit %s (HTTP %d, request %s): %s", e.Code, e.StatusCode, e.RequestID, e.Message)
}
func (e *APIError) Unwrap() error { return &HTTPError{StatusCode: e.StatusCode} }
// SubmitTask 使用调用方持久化的幂等键;SDK 不自动生成新键或自动重试写请求。
func (c *Client) SubmitTask(ctx context.Context, key string, in SubmitTaskRequest) (SubmitTaskResult, error) {
var out SubmitTaskResult
err := c.call(ctx, "POST", "/v1/tasks", key, in, &out)
return out, err
}
func (c *Client) GetTask(ctx context.Context, id string) (Task, error) {
var out Task
if !validID(id) {
return out, errors.New("invalid task id")
}
err := c.call(ctx, "GET", "/v1/tasks/"+id, "", nil, &out)
return out, err
}
func (c *Client) ListTasks(ctx context.Context, in ListTasksRequest) (TaskPage, error) {
var out TaskPage
q := url.Values{}
q.Set("queue_id", in.QueueID)
if in.State != "" {
q.Set("state", string(in.State))
}
if in.Limit != 0 {
q.Set("limit", strconv.Itoa(in.Limit))
}
if in.Cursor != "" {
q.Set("cursor", in.Cursor)
}
err := c.call(ctx, "GET", "/v1/tasks?"+q.Encode(), "", nil, &out)
return out, err
}
// CancelTask 当前只取消 WAITING 状态且不归属流程的任务;重复请求使用原键与原版本。
func (c *Client) CancelTask(ctx context.Context, id, key string, in CancelTaskRequest) (CancelTaskResult, error) {
var out CancelTaskResult
if !validID(id) {
return out, errors.New("invalid task id")
}
err := c.call(ctx, "POST", "/v1/tasks/"+id+":cancel", key, in, &out)
return out, err
}
func (c *Client) CreateQueue(ctx context.Context, key string, in CreateQueueRequest) (CreateQueueResult, error) {
var out CreateQueueResult
err := c.call(ctx, "POST", "/v1/queues", key, in, &out)
return out, err
}
func (c *Client) GetQueue(ctx context.Context, id string) (Queue, error) {
var out Queue
if !validID(id) {
return out, errors.New("invalid queue id")
}
err := c.call(ctx, "GET", "/v1/queues/"+id, "", nil, &out)
return out, err
}
func validID(id string) bool {
if len(id) != 32 {
return false
}
for _, ch := range id {
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') {
return false
}
}
return true
}
func (c *Client) call(ctx context.Context, method, path, key string, in, out any) error {
if c.token == "" {
return errors.New("Orbit token is required for business APIs")
}
if method != "GET" {
if len(key) < 1 || len(key) > 128 {
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
}
for _, ch := range key {
if ch < 33 || ch > 126 {
return errors.New("Idempotency-Key must not contain spaces or control characters")
}
}
}
var body []byte
var err error
if in != nil {
body, err = json.Marshal(in)
if err != nil {
return err
}
}
if len(body) > 512<<10 {
return errors.New("Orbit request exceeds 512 KiB")
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
response, err := c.http.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
const maxResponse = 1 << 20
raw, err := io.ReadAll(io.LimitReader(response.Body, maxResponse+1))
if err != nil {
return err
}
if len(raw) > maxResponse {
return errors.New("Orbit response exceeds 1 MiB")
}
var envelope struct {
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
decoded := json.Unmarshal(raw, &envelope)
if response.StatusCode < 200 || response.StatusCode >= 300 {
if decoded == nil && envelope.Error != nil && len(envelope.Error.Code) <= 64 && len(envelope.Error.Message) <= 1024 && validID(envelope.RequestID) {
return &APIError{response.StatusCode, envelope.Error.Code, envelope.Error.Message, envelope.RequestID}
}
return &HTTPError{StatusCode: response.StatusCode}
}
if decoded != nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" || envelope.Error != nil || !validID(envelope.RequestID) {
return errors.New("invalid Orbit response envelope")
}
return json.Unmarshal(envelope.Data, out)
}

72
sdk/go/tasks_test.go Normal file
View File

@@ -0,0 +1,72 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestTaskClientContractAndNoSilentRetry(t *testing.T) {
const id = "11111111111111111111111111111111"
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Header.Get("Authorization") != "Bearer example-token" || r.Header.Get("Idempotency-Key") != "stable" {
t.Error("lost authorization or identity")
}
raw, _ := io.ReadAll(r.Body)
if !strings.Contains(string(raw), "9007199254740993") {
t.Error("payload lost integer precision")
}
if calls.Load() == 1 {
w.WriteHeader(503)
_, _ = io.WriteString(w, `{"request_id":"`+id+`","error":{"code":"UNAVAILABLE","message":"retry original identity"}}`)
return
}
_, _ = io.WriteString(w, `{"request_id":"`+id+`","data":{"deduplicated":true,"task":{"id":"`+id+`","revision":"9007199254740993","payload":{"sequence":9007199254740993}}}}`)
}))
defer srv.Close()
client, err := NewClient(srv.URL, Options{Token: "example-token"})
if err != nil {
t.Fatal(err)
}
request := SubmitTaskRequest{QueueID: id, Payload: json.RawMessage(`{"sequence":9007199254740993}`)}
_, err = client.SubmitTask(context.Background(), "stable", request)
var apiErr *APIError
var httpErr *HTTPError
if !errors.As(err, &apiErr) || apiErr.Code != "UNAVAILABLE" || apiErr.RequestID != id || !errors.As(err, &httpErr) || httpErr.StatusCode != 503 || calls.Load() != 1 {
t.Fatalf("error/retry contract: %v, calls=%d", err, calls.Load())
}
out, err := client.SubmitTask(context.Background(), "stable", request)
if err != nil || !out.Deduplicated || out.Task.Revision != 9007199254740993 || string(out.Task.Payload) != `{"sequence":9007199254740993}` {
t.Fatalf("response: %+v %v", out, err)
}
}
func TestBusinessClientRedirectAndPathBoundaries(t *testing.T) {
var reached atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Store(true) }))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) }))
defer source.Close()
client, _ := NewClient(source.URL, Options{Token: "example-token"})
_, err := client.GetTask(context.Background(), strings.Repeat("1", 32))
var failure *HTTPError
if !errors.As(err, &failure) || failure.StatusCode != 307 || reached.Load() {
t.Fatalf("redirect leaked request: %v", err)
}
for _, id := range []string{"../queues", "abc?token=bad", strings.Repeat("A", 32)} {
if _, err := client.GetTask(context.Background(), id); err == nil {
t.Fatal("accepted unsafe ID")
}
}
missing, _ := NewClient(source.URL, Options{})
if _, err := missing.GetQueue(context.Background(), strings.Repeat("1", 32)); err == nil {
t.Fatal("missing token allowed")
}
}

200
sdk/go/types.go Normal file
View File

@@ -0,0 +1,200 @@
// Package orbit provides the standalone Orbit Go SDK.
package orbit
import (
"encoding/json"
"time"
)
// SubmitTaskRequest 是任务受理命令;可变配置在受理时固定为快照。
type SubmitTaskRequest struct {
// QueueID 目标队列,必须位于凭据所属命名空间。
QueueID string `json:"queue_id"`
// Handler 业务 Worker 的处理器名称。
Handler string `json:"handler"`
// HandlerVersion 固定处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 输入参数结构版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 文档,最大 256 KiB,保留精确数值文本。
Payload json.RawMessage `json:"payload"`
// BusinessKey 可选关联键,不提供业务效果幂等保证。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// TaskTimeoutMS 从受理时计算的总时限,单位毫秒。
TaskTimeoutMS uint64 `json:"task_timeout_ms"`
// RecoverySafety 外部效果未知时的恢复安全等级。
RecoverySafety RecoverySafety `json:"recovery_safety"`
// Retry 每次执行的有限尝试策略,包含首次尝试。
Retry RetryPolicy `json:"retry"`
}
// Task 是对外任务快照,不包含 Worker 令牌或凭据摘要。
type Task struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID string `json:"namespace_id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID string `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// PayloadVersion 受理时固定的参数契约版本。
PayloadVersion string `json:"payload_version"`
// Payload 业务无关 JSON 输入,保留数字精度。
Payload json.RawMessage `json:"payload"`
// Result 业务 JSON 结果;未取得结果时为空并省略。
Result json.RawMessage `json:"result,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// StartDeadlineAt 最晚开始时间;为空时使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// RecoverySafety 未知外部效果的恢复安全等级。
RecoverySafety RecoverySafety `json:"recovery_safety"`
// Retry 受理时冻结的尝试次数与时限策略。
Retry RetryPolicy `json:"retry"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CancelRequestedAt 取消意图时间;为空表示未请求取消。
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"`
// CompletedAt 终结时间;非终态为空。
CompletedAt *time.Time `json:"completed_at,omitempty"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type SubmitTaskResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task Task `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type CancelTaskRequest struct {
// Reason 管理操作原因,写入审计历史。
Reason string `json:"reason"`
// ExpectedRevision 调用者预期的资源版本,JSON 为十进制字符串。
ExpectedRevision uint64 `json:"expected_revision,string"`
}
type CancelTaskResult struct {
// Task 本次操作的提交回执快照;当前状态应另行查询。
Task Task `json:"task"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
type ListTasksRequest struct {
// QueueID 队列标识,必须属于当前命名空间。
QueueID string
// State 当前任务状态;终态不会被重复请求重开。
State TaskState
// Limit 分页大小,默认 20,上限 100。
Limit int
// Cursor 上页返回的游标,必须复用相同查询条件。
Cursor string
}
// TaskSummary 用于列表,避免在分页中重复传输大参数和结果。
type TaskSummary struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// QueueID 队列标识,必须属于当前命名空间。
QueueID string `json:"queue_id"`
// State 当前任务状态;终态不会被重复请求重开。
State TaskState `json:"state"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
// Handler Worker 处理器名称。
Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// DeadlineAt 任务总截止时间,UTC。
DeadlineAt time.Time `json:"deadline_at"`
// AttemptCount 已创建的尝试次数,包含首次尝试。
AttemptCount uint32 `json:"attempt_count"`
// CreatedAt 创建时间,UTC 微秒精度。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 最后变更时间,不能替代并发版本。
UpdatedAt time.Time `json:"updated_at"`
}
type TaskPage struct {
// Items 当前页任务摘要,不包含大体积参数与结果。
Items []TaskSummary `json:"items"`
// NextCursor 下一页游标;为空表示当前查询已无下一页。
NextCursor string `json:"next_cursor,omitempty"`
}
type CreateQueueRequest struct {
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
}
type Queue struct {
// ID 资源标识,32 位小写十六进制字符串。
ID string `json:"id"`
// NamespaceID 命名空间标识,由凭据确定隔离范围。
NamespaceID string `json:"namespace_id"`
// Name 队列名称,同一命名空间内唯一。
Name string `json:"name"`
// Paused 是否停止新执行;不禁止受理新任务。
Paused bool `json:"paused"`
// MaxConcurrency 最大同时执行数量,供执行阶段使用。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒开始数量上限,供执行阶段使用。
RatePerSecond uint32 `json:"rate_per_second"`
// Revision 并发版本,JSON 使用十进制字符串避免跨语言精度丢失。
Revision uint64 `json:"revision,string"`
}
type CreateQueueResult struct {
// Queue 本次操作返回的队列快照。
Queue Queue `json:"queue"`
// Deduplicated 是否复用了同一幂等身份的原始回执。
Deduplicated bool `json:"deduplicated"`
}
// TaskState 是服务端任务状态;未知状态应保留供升级兼容判断。
type TaskState string
const (
TaskWaiting TaskState = "WAITING"
TaskLeased TaskState = "LEASED"
TaskRunning TaskState = "RUNNING"
TaskNeedsReview TaskState = "NEEDS_REVIEW"
TaskSucceeded TaskState = "SUCCEEDED"
TaskFailed TaskState = "FAILED"
TaskCancelled TaskState = "CANCELLED"
)
// RecoverySafety 声明业务处理器面对未知外部效果时是否可以安全重入。
type RecoverySafety string
const (
RecoveryReconcileOnly RecoverySafety = "RECONCILE_ONLY"
RecoveryIdempotent RecoverySafety = "IDEMPOTENT"
)
// RetryPolicy 是有限执行策略;单位为毫秒,次数包含首次尝试。
type RetryPolicy struct {
MaxAttempts uint32 `json:"max_attempts"` // MaxAttempts 总尝试次数上限。
InitialDelayMS uint64 `json:"initial_delay_ms"` // InitialDelayMS 首次重试间隔。
MaxDelayMS uint64 `json:"max_delay_ms"` // MaxDelayMS 间隔上限。
AttemptTimeoutMS uint64 `json:"attempt_timeout_ms"` // AttemptTimeoutMS 单次尝试时限。
}