Merge remote-tracking branch 'origin/main' into codex/skill-token-shallow-water
# Conflicts: # internal/cli/schema_agent_metadata/index.json # internal/cli/schema_agent_metadata_audit.json # internal/cli/schema_catalog/catalog.json
This commit is contained in:
commit
e04ff5a12b
27 files changed
+5688
-189
No files matched your search
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
|
||||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||||
and machine-read why it exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
Scope of this branch: the five **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
@@ -92,6 +92,73 @@ Ownership-based cleanup:
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
### 5. Subscription-create retry orchestration and local guard
|
||||
|
||||
This policy covers all 16 public personal-event keys and every logical
|
||||
subscription in a multi-event command. It applies only before the ready
|
||||
marker; reconnecting an established Stream remains a separate mechanism.
|
||||
|
||||
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
|
||||
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
|
||||
process sends at most one subscription-create HTTP request for a logical
|
||||
subscription and performs no in-process automatic retry. The CLI persists
|
||||
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
|
||||
not persist or enforce the Agent/host attempt count across invocations.
|
||||
- ID resolution, `event consume`, and later `event status/stop` must use the
|
||||
same `--profile`. A user or conversation ID resolved under another profile
|
||||
must not be reused for the current subscription.
|
||||
- A logical subscription is keyed by the current profile/identity, event key,
|
||||
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
|
||||
does not create a new logical operation or reset the Agent/host budget.
|
||||
- For the Agent/host, `retryable=false` means
|
||||
`max_additional_attempts=0`.
|
||||
- For the Agent/host, `retryable=true` means
|
||||
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
|
||||
`next_retry_at` when present and must not retry early.
|
||||
- For the Agent/host, an omitted retryable value
|
||||
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
|
||||
failure stops the operation.
|
||||
- `in_flight` means the original logical request is still running.
|
||||
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
|
||||
it. These states must not recursively launch `event consume`, start a
|
||||
parallel equivalent subscription, or bypass the guard with a new subId or
|
||||
trace. The caller waits for the original request/guard or stops, while the
|
||||
Agent/host keeps its own orchestration count.
|
||||
- A multi-event command remains one original operation. A caller must not
|
||||
split out a failed event, reorder events, or restart the command to bypass
|
||||
a budget. Existing startup rollback cleans subscriptions created before a
|
||||
later item fails.
|
||||
|
||||
#### Local guard state operations
|
||||
|
||||
- The default open-edition state file is
|
||||
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
|
||||
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
|
||||
that edition's directory instead of `open`.
|
||||
- The identity directory is mode `0700`; both
|
||||
`personal_subscription_attempts.json` and
|
||||
`personal_subscription_attempts.lock` are mode `0600`.
|
||||
- A failure streak resets after 24h without another failure. A
|
||||
`terminal_hold` lasts 1h. Prefer waiting until the reported
|
||||
`next_retry_at`; do not clear the file as a normal retry mechanism.
|
||||
- For emergency recovery, first ensure that no subscription-create process is
|
||||
running for that identity. Delete only
|
||||
`personal_subscription_attempts.json`, never the lock file. This clears
|
||||
every protection record for that identity, not just one event.
|
||||
|
||||
**Verification**
|
||||
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
|
||||
contract and explicitly reject describing it as a CLI-persisted hard cap.
|
||||
- T5b (CLI): one process issues at most one create request per logical
|
||||
subscription; a changed subId/trace or process restart does not bypass the
|
||||
persisted fingerprint guard.
|
||||
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
|
||||
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
|
||||
and a partial startup still rolls back earlier subscriptions.
|
||||
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
|
||||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||||
operational recovery instructions.
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
|
||||
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
|
||||
sharedAuditSink = previousSink
|
||||
loadTokenForProfile = previousLoader
|
||||
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
|
||||
// The process-wide sink was initialized by TestMain. Preserve that
|
||||
// initialized state when restoring it: leaving auditSinkOnce unused
|
||||
// lets a later runner overwrite the live sink without closing its
|
||||
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
|
||||
auditSinkOnce.Do(func() {})
|
||||
if got := setupAuditSink(); got != previousSink {
|
||||
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
|
||||
}
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
|
||||
|
||||
@@ -1657,12 +1657,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
CorpID: "corp", UserID: "user", ClientID: "client",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
var cancelCount int
|
||||
var subscribeCount, cancelCount int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/event/sublist":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
|
||||
case "/subscription/user":
|
||||
subscribeCount++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
|
||||
case "/subscription/cancel":
|
||||
cancelCount++
|
||||
@@ -1697,8 +1698,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
|
||||
t.Fatal("invalid foreground consume succeeded")
|
||||
}
|
||||
if cancelCount == 0 {
|
||||
t.Fatal("failed foreground consume did not clean up subscription")
|
||||
if subscribeCount != 0 || cancelCount != 0 {
|
||||
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
|
||||
}
|
||||
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
|
||||
|
||||
@@ -161,7 +161,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
"subscribe-id", "rule", "event-types", "filter",
|
||||
"foreground", "force", "debug-raw-events",
|
||||
); err != nil {
|
||||
return fmt.Errorf("event consume: %w", err)
|
||||
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
personalOpts.Common = commonConsumeOptions{
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
|
||||
|
||||
type personalSubscriptionAttemptStore interface {
|
||||
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
|
||||
CompleteSuccess(*personal.AttemptClaim) error
|
||||
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
|
||||
Release(*personal.AttemptClaim) error
|
||||
}
|
||||
|
||||
var (
|
||||
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
|
||||
return personal.NewAttemptStore(workDir)
|
||||
}
|
||||
personalSubscriptionAttemptNow = time.Now
|
||||
)
|
||||
|
||||
type personalSubscriptionAttemptItem struct {
|
||||
eventKey string
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type personalSubscriptionAttemptReservation struct {
|
||||
store personalSubscriptionAttemptStore
|
||||
claim *personal.AttemptClaim
|
||||
items []personalSubscriptionAttemptItem
|
||||
}
|
||||
|
||||
type personalSubscriptionFailureClass struct {
|
||||
retryability personal.Retryability
|
||||
retryAfter time.Duration
|
||||
code string
|
||||
traceID string
|
||||
reason string
|
||||
auth bool
|
||||
}
|
||||
|
||||
func reservePersonalSubscriptionAttempts(
|
||||
workDir string,
|
||||
client *personal.Client,
|
||||
identity personal.Identity,
|
||||
profileSelector string,
|
||||
plans []personalConsumeOptions,
|
||||
) (*personalSubscriptionAttemptReservation, error) {
|
||||
if len(plans) == 0 {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: no subscription attempts to reserve"),
|
||||
)
|
||||
}
|
||||
if client == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil subscription control client"),
|
||||
)
|
||||
}
|
||||
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
|
||||
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
|
||||
specs := make([]personal.AttemptSpec, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
prepared, err := preparePersonalSubscription(identity, plan)
|
||||
if err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
fingerprint := personal.Fingerprint(
|
||||
client.BaseURL,
|
||||
prepared.Request.IdempotencyKey,
|
||||
profileSelector,
|
||||
)
|
||||
items = append(items, personalSubscriptionAttemptItem{
|
||||
eventKey: prepared.EventKey,
|
||||
fingerprint: fingerprint,
|
||||
})
|
||||
specs = append(specs, personal.AttemptSpec{
|
||||
Fingerprint: fingerprint,
|
||||
EventKey: prepared.EventKey,
|
||||
})
|
||||
}
|
||||
|
||||
store := personalNewSubscriptionAttemptStore(workDir)
|
||||
if store == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt store is unavailable"),
|
||||
)
|
||||
}
|
||||
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
|
||||
if err != nil {
|
||||
var blocked *personal.AttemptBlockedError
|
||||
if errors.As(err, &blocked) {
|
||||
return nil, personalSubscriptionBlockedError(blocked)
|
||||
}
|
||||
return nil, personalSubscriptionGuardError(err)
|
||||
}
|
||||
return &personalSubscriptionAttemptReservation{
|
||||
store: store,
|
||||
claim: claim,
|
||||
items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionEndpoint(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" ||
|
||||
(!strings.EqualFold(parsed.Scheme, "http") &&
|
||||
!strings.EqualFold(parsed.Scheme, "https")) {
|
||||
if err == nil {
|
||||
err = errors.New("an absolute http(s) URL is required")
|
||||
}
|
||||
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
|
||||
const (
|
||||
leaseOverhead = 30 * time.Second
|
||||
minLease = time.Minute
|
||||
maxLease = 10 * time.Minute
|
||||
)
|
||||
if batchSize < 1 {
|
||||
batchSize = 1
|
||||
}
|
||||
timeout := config.HTTPTimeout
|
||||
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
|
||||
timeout = client.HTTPClient.Timeout
|
||||
}
|
||||
maxRequestBudget := maxLease - leaseOverhead
|
||||
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
|
||||
return maxLease
|
||||
}
|
||||
lease := timeout*time.Duration(batchSize) + leaseOverhead
|
||||
if lease < minLease {
|
||||
return minLease
|
||||
}
|
||||
return lease
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
)
|
||||
}
|
||||
if err := r.store.CompleteSuccess(r.claim); err != nil {
|
||||
return personalSubscriptionGuardError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeFailure(
|
||||
ctx context.Context,
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
if r == nil {
|
||||
return cause
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
))
|
||||
}
|
||||
if failedIndex < 0 || failedIndex >= len(r.items) ||
|
||||
succeededCount < 0 || succeededCount > failedIndex {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: invalid subscription attempt completion indexes"),
|
||||
))
|
||||
}
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
// Cancellation is not a failed attempt. Restoring the claim normally
|
||||
// completes immediately; if the lock cannot be acquired, leaving the
|
||||
// finite lease behind is still safer than recording a false failure.
|
||||
_ = r.store.Release(r.claim)
|
||||
return cause
|
||||
}
|
||||
|
||||
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
|
||||
if override != nil {
|
||||
classification = *override
|
||||
}
|
||||
succeeded := make([]string, 0, succeededCount)
|
||||
for i := 0; i < succeededCount; i++ {
|
||||
succeeded = append(succeeded, r.items[i].fingerprint)
|
||||
}
|
||||
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
|
||||
Fingerprint: r.items[failedIndex].fingerprint,
|
||||
Retryability: classification.retryability,
|
||||
RetryAfter: classification.retryAfter,
|
||||
ErrorCode: classification.code,
|
||||
TraceID: classification.traceID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionGuardError(errors.Join(cause, err))
|
||||
}
|
||||
return personalSubscriptionFailureError(cause, classification, hold)
|
||||
}
|
||||
|
||||
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
||||
}
|
||||
|
||||
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
|
||||
classification := personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_unknown",
|
||||
}
|
||||
|
||||
var apiErr *personal.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
classification.code = strings.TrimSpace(apiErr.Code)
|
||||
classification.traceID = strings.TrimSpace(apiErr.TraceID)
|
||||
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
|
||||
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
|
||||
switch {
|
||||
case apiErr.Retryable != nil && *apiErr.Retryable:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_server_retryable"
|
||||
case apiErr.Retryable != nil:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_server_non_retryable"
|
||||
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
|
||||
apiErr.HTTPStatus == http.StatusTooEarly ||
|
||||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
|
||||
apiErr.HTTPStatus >= http.StatusInternalServerError:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_transient_http"
|
||||
case apiErr.HTTPStatus == http.StatusUnauthorized ||
|
||||
apiErr.HTTPStatus == http.StatusForbidden:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_business_rejected"
|
||||
case personalSubscriptionErrorHasSubscribeID(apiErr):
|
||||
// A few legacy/proxy error shapes include an existing subscription
|
||||
// ID without a stable server contract. Keep the response as an
|
||||
// error, but do not turn that unverified shape into a one-hour hold.
|
||||
classification.reason = "personal_subscription_unverified_existing_id"
|
||||
case apiErr.HTTPStatus >= http.StatusBadRequest:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_http_rejected"
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_timeout"
|
||||
return classification
|
||||
}
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_invalid"
|
||||
return classification
|
||||
}
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
classification.auth = true
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
|
||||
if apiErr == nil {
|
||||
return false
|
||||
}
|
||||
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
|
||||
return ok && strings.TrimSpace(subscribeID) != ""
|
||||
}
|
||||
|
||||
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
|
||||
if apiErr == nil {
|
||||
return 0
|
||||
}
|
||||
var delay time.Duration
|
||||
if apiErr.RetryAfterSeconds != nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
|
||||
}
|
||||
if apiErr.NextRetryAt != nil {
|
||||
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
|
||||
}
|
||||
if raw, ok := apiErr.Details["retry_after"].(string); ok {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
|
||||
} else if next, err := http.ParseTime(raw); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, next.Sub(now))
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func personalRetrySeconds(seconds int64) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
if seconds > math.MaxInt64/int64(time.Second) {
|
||||
return time.Duration(math.MaxInt64)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
|
||||
if right > left {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func personalSubscriptionTerminalBusinessCode(raw string) bool {
|
||||
code := strings.ToUpper(strings.TrimSpace(raw))
|
||||
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
|
||||
code = replacer.Replace(code)
|
||||
|
||||
// Keep this list deliberately conservative. Unknown server codes must stay
|
||||
// unknown so a newly introduced transient condition cannot accidentally be
|
||||
// converted into a one-hour terminal hold.
|
||||
switch code {
|
||||
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
|
||||
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
|
||||
"PARAM_ERROR", "PARAMETER_ERROR",
|
||||
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
|
||||
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
|
||||
"FORBIDDEN", "UNAUTHORIZED",
|
||||
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
|
||||
"UNIFIED_APP_ID_NOT_FOUND":
|
||||
return true
|
||||
}
|
||||
|
||||
// Resource-qualified variants are stable business-rejection shapes. Avoid
|
||||
// broad substring matching (for example, RETRY_REQUIRED must remain
|
||||
// unknown).
|
||||
for _, suffix := range []string{
|
||||
"_NOT_BELONG_TO_ORG",
|
||||
"_DOES_NOT_BELONG_TO_ORG",
|
||||
"_NOT_FOUND",
|
||||
"_NOT_EXIST",
|
||||
"_NOT_SUPPORTED",
|
||||
"_UNSUPPORTED",
|
||||
"_NO_PERMISSION",
|
||||
"_PERMISSION_DENIED",
|
||||
"_ACCESS_DENIED",
|
||||
} {
|
||||
if strings.HasSuffix(code, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
||||
for _, marker := range []string{
|
||||
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
|
||||
} {
|
||||
if strings.Contains(code, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionFailureError(
|
||||
cause error,
|
||||
classification personalSubscriptionFailureClass,
|
||||
hold personal.AttemptHold,
|
||||
) error {
|
||||
options := personalSubscriptionErrorOptions(
|
||||
classification.retryability,
|
||||
hold.RetryAfter,
|
||||
hold.NextAllowedAt,
|
||||
classification.code,
|
||||
classification.traceID,
|
||||
classification.reason,
|
||||
cause,
|
||||
)
|
||||
message := cause.Error()
|
||||
if classification.retryability == personal.RetryabilityNonRetryable {
|
||||
if classification.auth {
|
||||
return apperrors.NewAuth(message, options...)
|
||||
}
|
||||
return apperrors.NewValidation(message, options...)
|
||||
}
|
||||
return apperrors.NewAPI(message, options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
|
||||
if blocked == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil blocked subscription attempt"),
|
||||
)
|
||||
}
|
||||
reason := "personal_subscription_" + string(blocked.State)
|
||||
options := personalSubscriptionErrorOptions(
|
||||
blocked.Retryability,
|
||||
blocked.RetryAfter,
|
||||
blocked.NextAllowedAt,
|
||||
blocked.ErrorCode,
|
||||
blocked.TraceID,
|
||||
reason,
|
||||
blocked,
|
||||
)
|
||||
if blocked.Retryability == personal.RetryabilityNonRetryable {
|
||||
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
|
||||
return apperrors.NewAuth(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewValidation(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewAPI(blocked.Error(), options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorOptions(
|
||||
retryability personal.Retryability,
|
||||
retryAfter time.Duration,
|
||||
nextRetryAt time.Time,
|
||||
code string,
|
||||
traceID string,
|
||||
reason string,
|
||||
cause error,
|
||||
) []apperrors.Option {
|
||||
options := []apperrors.Option{
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithCause(cause),
|
||||
}
|
||||
if retryable, known := retryability.Value(); known {
|
||||
options = append(options, apperrors.WithRetryable(retryable))
|
||||
}
|
||||
if retryAfter > 0 {
|
||||
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
|
||||
}
|
||||
if !nextRetryAt.IsZero() {
|
||||
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
|
||||
}
|
||||
if code != "" || traceID != "" {
|
||||
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
TraceID: strings.TrimSpace(traceID),
|
||||
ServerErrorCode: strings.TrimSpace(code),
|
||||
}))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
|
||||
if delay <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := int64(delay / time.Second)
|
||||
if delay%time.Second != 0 {
|
||||
seconds++
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func personalSubscriptionGuardError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: subscription attempt guard failed")
|
||||
}
|
||||
return apperrors.NewInternal(
|
||||
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_guard_failed"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionValidationError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: invalid subscription parameters")
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
cause.Error(),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_invalid"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
|
||||
return personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_local_failure",
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -227,7 +227,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
@@ -238,7 +238,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
@@ -255,12 +255,12 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
cfg := consume.Config{
|
||||
@@ -284,16 +284,100 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
if err := personalConsumeRun(ctx, cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Complete all local validation before creating a remote subscription.
|
||||
// Otherwise an invalid output mode can repeatedly create and roll back a
|
||||
// valid subscription when an outer agent relaunches the command.
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundSource *source.PersonalSource
|
||||
if opts.Common.Foreground {
|
||||
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
var attempt *personalSubscriptionAttemptReservation
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
attempt, err = reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
[]personalConsumeOptions{opts},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
}
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
err = attempt.completeFailure(ctx, 0, 0, err, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
if sub == nil {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned an empty subscription"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sub.SubscribeID) == "" {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned empty subscribe_id"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -303,11 +387,21 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
// unsubscribed on exit
|
||||
@@ -317,59 +411,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
defer cleanup(context.Background())
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
ReadySubscribeID: sub.SubscribeID,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
cfg.EventKey = eventKey
|
||||
cfg.ReadySubscribeID = sub.SubscribeID
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
@@ -378,18 +430,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
Source: foregroundSource,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -403,7 +455,7 @@ type personalMultiSubscription struct {
|
||||
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
plans, err := preparePersonalMultiOptions(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
@@ -414,7 +466,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(false, opts.Flatten)
|
||||
|
||||
@@ -428,15 +480,16 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
baseCfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -451,11 +504,11 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(baseCfg); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
@@ -464,13 +517,23 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
attempt, err := reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
plans,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
created := make([]personalMultiSubscription, 0, len(plans))
|
||||
cleanup := func() {
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
ids := make([]string, 0, len(created))
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
id := strings.TrimSpace(created[i].Sub.SubscribeID)
|
||||
ids = append(ids, id)
|
||||
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
|
||||
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
@@ -480,26 +543,45 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
failAndCleanup := func(
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
|
||||
// Persist the hold (or release a canceled claim) before any potentially
|
||||
// slow remote rollback. Otherwise the attempt lease can expire while
|
||||
// deleting earlier subscriptions and admit a duplicate create batch.
|
||||
cleanup(cleanupCtx)
|
||||
return completed
|
||||
}
|
||||
seenSubscribeIDs := make(map[string]struct{}, len(plans))
|
||||
for _, plan := range plans {
|
||||
for i, plan := range plans {
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
err = failAndCleanup(i, len(created), err, nil)
|
||||
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
|
||||
}
|
||||
if sub == nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
|
||||
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
id := strings.TrimSpace(sub.SubscribeID)
|
||||
if id == "" {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
if _, exists := seenSubscribeIDs[id]; exists {
|
||||
_ = personalDeleteSubscription(client, context.Background(), id)
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
|
||||
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
seenSubscribeIDs[id] = struct{}{}
|
||||
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
|
||||
@@ -512,11 +594,17 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
|
||||
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
cause = failAndCleanup(i, len(created)-1, cause, &classification)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
}
|
||||
defer cleanup()
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
defer cleanup(context.Background())
|
||||
|
||||
specs := make([]consume.ConsumerSpec, 0, len(created))
|
||||
for _, item := range created {
|
||||
@@ -705,6 +793,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
Request personal.CreateSubscriptionRequest
|
||||
}
|
||||
|
||||
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
return personalPreparedSubscription{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
|
||||
sub, err := personalCreateSubscription(client, ctx, plan.Request)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, plan.EventKey, plan.RuleType, nil
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||
@@ -727,42 +868,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
plan, err := preparePersonalSubscription(identity, opts)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := personalCreateSubscription(client, ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
return createPreparedPersonalSubscription(ctx, client, plan)
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
@@ -829,7 +939,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
@@ -1091,6 +1201,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
version := strings.TrimSpace(RawVersion())
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
client.ClientVersion = version
|
||||
client.UserAgent = "dws-cli/" + version
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
|
||||
wantErr := errors.New("consume")
|
||||
cmd := newPersonalCoverageCommand()
|
||||
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
|
||||
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
before := deletes
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
|
||||
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
@@ -251,7 +252,7 @@ func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
|
||||
@@ -379,7 +380,158 @@ func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", errors.New("second subscription failed")
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("partial creation unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
return errors.New("state disk failed")
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("run-state failure unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", context.Canceled
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
|
||||
if cleanupCtx.Err() == nil {
|
||||
t.Fatal("cancellation cleanup received a live context")
|
||||
}
|
||||
order = append(order, "delete")
|
||||
return cleanupCtx.Err()
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd.SetContext(ctx)
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
|
||||
t.Fatalf("release/canceled-cleanup order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ensure func(int, personalConsumeOptions) *personal.Subscription
|
||||
@@ -641,16 +793,21 @@ func installPersonalManySeams(t *testing.T) func() {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldLookup := personalLookupDefinition
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldRunMany := personalConsumeRunMany
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
return func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalLookupDefinition = oldLookup
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
@@ -659,3 +816,68 @@ func installPersonalManySeams(t *testing.T) func() {
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
}
|
||||
}
|
||||
|
||||
type personalNoopAttemptStore struct{}
|
||||
|
||||
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
|
||||
fingerprints := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
fingerprints = append(fingerprints, spec.Fingerprint)
|
||||
}
|
||||
return &personal.AttemptClaim{
|
||||
AttemptID: "test-attempt",
|
||||
Fingerprints: fingerprints,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type personalOrderingAttemptStore struct {
|
||||
order *[]string
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Claim(
|
||||
specs []personal.AttemptSpec,
|
||||
lease time.Duration,
|
||||
) (*personal.AttemptClaim, error) {
|
||||
return personalNoopAttemptStore{}.Claim(specs, lease)
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "complete_success")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
*s.order = append(*s.order, "complete_failure")
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "release")
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_files": 160,
|
||||
"hint_files": 54,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"source_hash": "sha256:b227b19dccbe78d2df26dc283449f7a85ef490a0d7e7f2d644a411230a7d850c",
|
||||
"source_hash": "sha256:862734fb976a4c9c78d010b7391c74822f37805f5acc650551f4ac60f701c13a",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 26,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:8b6f42e8729815d12a9d981d406b5e323eef110806f03502803fdfca0e112af2",
|
||||
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
|
||||
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
|
||||
"surface_products": 26,
|
||||
"surface_tools": 845,
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
}),
|
||||
)
|
||||
typed := err.(*Error)
|
||||
if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
|
||||
if typed.Retryable || !typed.RetryableSet || typed.ServerDiag.TraceID != "trace" {
|
||||
t.Fatalf("diagnostics options = %#v", typed)
|
||||
}
|
||||
if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
|
||||
|
||||
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
|
||||
// Override retryable if server explicitly specified.
|
||||
if diag.ServerRetryable != nil {
|
||||
e.Retryable = *diag.ServerRetryable
|
||||
e.RetryableSet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-18
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
@@ -41,20 +42,23 @@ const (
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
type Error struct {
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
NextRetryAt *time.Time
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
|
||||
func WithRetryable(retryable bool) Option {
|
||||
return func(err *Error) {
|
||||
err.Retryable = retryable
|
||||
err.RetryableSet = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfterSeconds records the server-recommended delay before a retry.
|
||||
// A zero delay is meaningful and is therefore preserved; negative values are
|
||||
// ignored as invalid server guidance.
|
||||
func WithRetryAfterSeconds(seconds int64) Option {
|
||||
return func(err *Error) {
|
||||
if seconds < 0 {
|
||||
return
|
||||
}
|
||||
value := seconds
|
||||
err.RetryAfterSeconds = &value
|
||||
}
|
||||
}
|
||||
|
||||
// WithNextRetryAt records the absolute time at which a retry may be attempted.
|
||||
func WithNextRetryAt(next time.Time) Option {
|
||||
return func(err *Error) {
|
||||
if next.IsZero() {
|
||||
return
|
||||
}
|
||||
value := next.UTC()
|
||||
err.NextRetryAt = &value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.ServerKey != "" {
|
||||
errorPayload["server_key"] = typed.ServerKey
|
||||
}
|
||||
if typed.Retryable {
|
||||
errorPayload["retryable"] = true
|
||||
if typed.RetryableSet {
|
||||
errorPayload["retryable"] = typed.Retryable
|
||||
}
|
||||
if typed.RetryAfterSeconds != nil {
|
||||
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
|
||||
}
|
||||
if typed.NextRetryAt != nil {
|
||||
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
errorPayload["hint"] = typed.Hint
|
||||
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, tui.Dim(line))
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, tui.Warning("Retryable: true"))
|
||||
if typed.RetryableSet {
|
||||
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
|
||||
}
|
||||
if typed.RetryAfterSeconds != nil {
|
||||
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
|
||||
}
|
||||
if typed.NextRetryAt != nil {
|
||||
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
// Always shown when present: Trace ID, Server Code
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExitCodeByCategory(t *testing.T) {
|
||||
@@ -77,6 +78,99 @@ func TestPrintJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
next := time.Date(2026, time.July, 30, 4, 5, 6, 0, time.FixedZone("CST", 8*60*60))
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantRetryable string
|
||||
wantRetryAfter bool
|
||||
wantNextRetryAt bool
|
||||
}{
|
||||
{
|
||||
name: "unknown is omitted",
|
||||
err: NewAPI("unknown"),
|
||||
},
|
||||
{
|
||||
name: "explicit false is preserved",
|
||||
err: NewValidation("terminal", WithRetryable(false)),
|
||||
wantRetryable: `"retryable": false`,
|
||||
},
|
||||
{
|
||||
name: "explicit true with timing",
|
||||
err: NewAPI("transient", WithRetryable(true), WithRetryAfterSeconds(30), WithNextRetryAt(next)),
|
||||
wantRetryable: `"retryable": true`,
|
||||
wantRetryAfter: true,
|
||||
wantNextRetryAt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var jsonOut strings.Builder
|
||||
if err := PrintJSON(&jsonOut, tt.err); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
gotJSON := jsonOut.String()
|
||||
if tt.wantRetryable == "" {
|
||||
if strings.Contains(gotJSON, `"retryable"`) {
|
||||
t.Fatalf("unknown retryability must be omitted: %s", gotJSON)
|
||||
}
|
||||
} else if !strings.Contains(gotJSON, tt.wantRetryable) {
|
||||
t.Fatalf("missing %s in %s", tt.wantRetryable, gotJSON)
|
||||
}
|
||||
if got := strings.Contains(gotJSON, `"retry_after_seconds": 30`); got != tt.wantRetryAfter {
|
||||
t.Fatalf("retry_after_seconds presence = %v, want %v: %s", got, tt.wantRetryAfter, gotJSON)
|
||||
}
|
||||
if got := strings.Contains(gotJSON, `"next_retry_at": "2026-07-29T20:05:06Z"`); got != tt.wantNextRetryAt {
|
||||
t.Fatalf("next_retry_at presence = %v, want %v: %s", got, tt.wantNextRetryAt, gotJSON)
|
||||
}
|
||||
|
||||
var humanOut strings.Builder
|
||||
if err := PrintHuman(&humanOut, tt.err); err != nil {
|
||||
t.Fatalf("PrintHuman() error = %v", err)
|
||||
}
|
||||
gotHuman := humanOut.String()
|
||||
if tt.wantRetryable == "" {
|
||||
if strings.Contains(gotHuman, "Retryable:") {
|
||||
t.Fatalf("unknown retryability must be omitted: %s", gotHuman)
|
||||
}
|
||||
} else {
|
||||
want := "Retryable: true"
|
||||
if strings.Contains(tt.wantRetryable, "false") {
|
||||
want = "Retryable: false"
|
||||
}
|
||||
if !strings.Contains(gotHuman, want) {
|
||||
t.Fatalf("missing %q in %s", want, gotHuman)
|
||||
}
|
||||
}
|
||||
if tt.wantRetryAfter && !strings.Contains(gotHuman, "Retry After: 30s") {
|
||||
t.Fatalf("missing retry delay in %s", gotHuman)
|
||||
}
|
||||
if tt.wantNextRetryAt && !strings.Contains(gotHuman, "Next Retry At: 2026-07-29T20:05:06Z") {
|
||||
t.Fatalf("missing next retry time in %s", gotHuman)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := NewAPI(
|
||||
"invalid timing",
|
||||
WithRetryAfterSeconds(-1),
|
||||
WithNextRetryAt(time.Time{}),
|
||||
).(*Error)
|
||||
if err.RetryAfterSeconds != nil || err.NextRetryAt != nil {
|
||||
t.Fatalf("invalid retry timing was retained: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package personal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// AttemptStateFileName stores retry suppression state for personal
|
||||
// subscription creation. It intentionally does not share the successful
|
||||
// subscription run-state file.
|
||||
AttemptStateFileName = "personal_subscription_attempts.json"
|
||||
// AttemptStateLockFileName serializes cross-process attempt-state changes.
|
||||
AttemptStateLockFileName = "personal_subscription_attempts.lock"
|
||||
|
||||
attemptStateVersion = 1
|
||||
|
||||
attemptLockWaitTimeout = 5 * time.Second
|
||||
attemptLockRetryDelay = 25 * time.Millisecond
|
||||
attemptFailureReset = 24 * time.Hour
|
||||
attemptTerminalHold = time.Hour
|
||||
attemptMaxFieldLength = 256
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAttemptClaimStale means another process replaced at least one claimed
|
||||
// fingerprint. Completion is rejected as a unit so an old process cannot
|
||||
// overwrite newer state.
|
||||
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
|
||||
)
|
||||
|
||||
// AttemptState is the persisted lifecycle state of one subscription attempt.
|
||||
type AttemptState string
|
||||
|
||||
const (
|
||||
AttemptStateInFlight AttemptState = "in_flight"
|
||||
AttemptStateCooldown AttemptState = "cooldown"
|
||||
AttemptStateTerminalHold AttemptState = "terminal_hold"
|
||||
)
|
||||
|
||||
// Retryability preserves the distinction between an explicit server decision
|
||||
// and an error for which the server did not provide retry guidance.
|
||||
type Retryability string
|
||||
|
||||
const (
|
||||
RetryabilityUnknown Retryability = "unknown"
|
||||
RetryabilityRetryable Retryability = "retryable"
|
||||
RetryabilityNonRetryable Retryability = "non_retryable"
|
||||
)
|
||||
|
||||
// Value converts retryability to a bool while preserving whether it is known.
|
||||
func (r Retryability) Value() (value bool, known bool) {
|
||||
switch r {
|
||||
case RetryabilityRetryable:
|
||||
return true, true
|
||||
case RetryabilityNonRetryable:
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// AttemptSpec identifies one normalized subscription request. Fingerprint must
|
||||
// come from Fingerprint; only the digest and the non-sensitive event key are
|
||||
// persisted.
|
||||
type AttemptSpec struct {
|
||||
Fingerprint string
|
||||
EventKey string
|
||||
}
|
||||
|
||||
// AttemptClaim is the ownership token returned by Claim. Callers must finish
|
||||
// it with CompleteSuccess, CompleteFailure, or Release.
|
||||
type AttemptClaim struct {
|
||||
AttemptID string
|
||||
Fingerprints []string
|
||||
|
||||
previous map[string]attemptPrevious
|
||||
}
|
||||
|
||||
type attemptPrevious struct {
|
||||
record attemptRecord
|
||||
present bool
|
||||
}
|
||||
|
||||
// AttemptBlockedError reports a local suppression decision. It never includes
|
||||
// the raw endpoint, idempotency key, profile selector, rule parameter, or
|
||||
// filter.
|
||||
type AttemptBlockedError struct {
|
||||
Fingerprint string
|
||||
EventKey string
|
||||
State AttemptState
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
NextAllowedAt time.Time
|
||||
FailureCount int
|
||||
ErrorCode string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
func (e *AttemptBlockedError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"personal event: subscription attempt %s until %s",
|
||||
e.State,
|
||||
e.NextAllowedAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
|
||||
// AttemptFailure describes the one failed item in a claimed batch.
|
||||
type AttemptFailure struct {
|
||||
Fingerprint string
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
ErrorCode string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// AttemptHold is the persisted suppression decision after CompleteFailure.
|
||||
type AttemptHold struct {
|
||||
Fingerprint string
|
||||
State AttemptState
|
||||
Retryability Retryability
|
||||
RetryAfter time.Duration
|
||||
NextAllowedAt time.Time
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
// AttemptStoreOption customizes an AttemptStore.
|
||||
type AttemptStoreOption func(*AttemptStore)
|
||||
|
||||
// WithAttemptClock installs a clock, primarily for deterministic tests.
|
||||
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
|
||||
return func(store *AttemptStore) {
|
||||
if now != nil {
|
||||
store.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
|
||||
// only as CAS tokens and must not contain request data.
|
||||
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
|
||||
return func(store *AttemptStore) {
|
||||
if generate != nil {
|
||||
store.newID = generate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AttemptStore coordinates personal subscription creation across CLI
|
||||
// processes sharing one identity work directory.
|
||||
type AttemptStore struct {
|
||||
workDir string
|
||||
now func() time.Time
|
||||
newID func() (string, error)
|
||||
|
||||
readFile func(string) ([]byte, error)
|
||||
mkdirAll func(string, os.FileMode) error
|
||||
tryAcquire func(string) (*eventlock.File, error)
|
||||
remove func(string) error
|
||||
marshal func(any, string, string) ([]byte, error)
|
||||
writeFile func(string, []byte, os.FileMode) error
|
||||
chmod func(string, os.FileMode) error
|
||||
rename func(string, string) error
|
||||
sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
|
||||
// identity-specific personal event work directory.
|
||||
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
|
||||
store := &AttemptStore{
|
||||
workDir: strings.TrimSpace(workDir),
|
||||
now: time.Now,
|
||||
newID: newAttemptID,
|
||||
readFile: os.ReadFile,
|
||||
mkdirAll: os.MkdirAll,
|
||||
tryAcquire: eventlock.TryAcquire,
|
||||
remove: os.Remove,
|
||||
marshal: json.MarshalIndent,
|
||||
writeFile: os.WriteFile,
|
||||
chmod: os.Chmod,
|
||||
rename: os.Rename,
|
||||
sleep: time.Sleep,
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(store)
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
|
||||
// and optional profile selectors into a stable non-reversible store key.
|
||||
// Passing the active selector ensures two profiles resolving to the same
|
||||
// corp/user can recover independently after a profile change.
|
||||
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
|
||||
endpoint = normalizeAttemptEndpoint(endpoint)
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if endpoint == "" || idempotencyKey == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
|
||||
writeFingerprintPart(h, endpoint)
|
||||
writeFingerprintPart(h, idempotencyKey)
|
||||
for _, selector := range profileSelector {
|
||||
if selector = strings.TrimSpace(selector); selector != "" {
|
||||
writeFingerprintPart(h, selector)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
type fingerprintWriter interface {
|
||||
Write([]byte) (int, error)
|
||||
}
|
||||
|
||||
func writeFingerprintPart(w fingerprintWriter, value string) {
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
|
||||
_, _ = w.Write(size[:])
|
||||
_, _ = w.Write([]byte(value))
|
||||
}
|
||||
|
||||
func normalizeAttemptEndpoint(raw string) string {
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
|
||||
// still suppressed, none are reserved and an AttemptBlockedError is returned.
|
||||
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
|
||||
normalized, err := normalizeAttemptSpecs(specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lease <= 0 {
|
||||
return nil, errors.New("personal event: subscription attempt lease must be positive")
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptID, err := s.newID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
|
||||
}
|
||||
attemptID = strings.TrimSpace(attemptID)
|
||||
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
|
||||
return nil, errors.New("personal event: generated subscription attempt id is invalid")
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
claim := &AttemptClaim{
|
||||
AttemptID: attemptID,
|
||||
Fingerprints: make([]string, 0, len(normalized)),
|
||||
previous: make(map[string]attemptPrevious, len(normalized)),
|
||||
}
|
||||
var blocked *AttemptBlockedError
|
||||
err = s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pruneAttemptRecords(state, now)
|
||||
|
||||
for _, spec := range normalized {
|
||||
record, ok := state.Records[spec.Fingerprint]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if candidate := blockedAttempt(record, now); candidate != nil {
|
||||
blocked = candidate
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, spec := range normalized {
|
||||
previous, present := state.Records[spec.Fingerprint]
|
||||
claim.previous[spec.Fingerprint] = attemptPrevious{
|
||||
record: previous,
|
||||
present: present,
|
||||
}
|
||||
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
|
||||
record := previous
|
||||
record.Fingerprint = spec.Fingerprint
|
||||
record.EventKey = spec.EventKey
|
||||
record.State = AttemptStateInFlight
|
||||
record.AttemptID = attemptID
|
||||
record.LeaseUntil = now.Add(lease)
|
||||
record.NextAllowedAt = time.Time{}
|
||||
record.Retryability = ""
|
||||
record.ErrorCode = ""
|
||||
record.TraceID = ""
|
||||
state.Records[spec.Fingerprint] = record
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked != nil {
|
||||
return nil, blocked
|
||||
}
|
||||
return claim, nil
|
||||
}
|
||||
|
||||
// CompleteSuccess clears failure history for every item in a successful
|
||||
// claimed batch.
|
||||
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteFailure records the failed item, clears successfully completed
|
||||
// items, and restores every unexecuted item to its exact pre-claim state.
|
||||
func (s *AttemptStore) CompleteFailure(
|
||||
claim *AttemptClaim,
|
||||
succeeded []string,
|
||||
failure AttemptFailure,
|
||||
) (AttemptHold, error) {
|
||||
var zero AttemptHold
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
|
||||
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
|
||||
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
|
||||
}
|
||||
if !validRetryability(failure.Retryability) {
|
||||
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
|
||||
}
|
||||
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
var hold AttemptHold
|
||||
err = s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
switch {
|
||||
case fingerprint == failure.Fingerprint:
|
||||
previous := claim.previous[fingerprint]
|
||||
record := previous.record
|
||||
count := record.FailureCount
|
||||
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
|
||||
count = 0
|
||||
}
|
||||
count++
|
||||
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
|
||||
record.Fingerprint = fingerprint
|
||||
record.EventKey = state.Records[fingerprint].EventKey
|
||||
record.State = stateName
|
||||
record.AttemptID = ""
|
||||
record.LeaseUntil = time.Time{}
|
||||
record.FailureCount = count
|
||||
record.LastFailureAt = now
|
||||
record.NextAllowedAt = now.Add(delay)
|
||||
record.Retryability = failure.Retryability
|
||||
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
|
||||
record.TraceID = boundedAttemptField(failure.TraceID)
|
||||
state.Records[fingerprint] = record
|
||||
hold = AttemptHold{
|
||||
Fingerprint: fingerprint,
|
||||
State: stateName,
|
||||
Retryability: failure.Retryability,
|
||||
RetryAfter: delay,
|
||||
NextAllowedAt: record.NextAllowedAt,
|
||||
FailureCount: count,
|
||||
}
|
||||
case succeededSet[fingerprint]:
|
||||
delete(state.Records, fingerprint)
|
||||
default:
|
||||
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
||||
}
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return hold, nil
|
||||
}
|
||||
|
||||
// Release restores every claimed item without recording a remote failure. It
|
||||
// is intended for local failures before a subscription request is sent.
|
||||
func (s *AttemptStore) Release(claim *AttemptClaim) error {
|
||||
if err := validateAttemptClaim(claim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withLock(func() error {
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimOwnsAll(state, claim) {
|
||||
return ErrAttemptClaimStale
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
||||
}
|
||||
return s.write(state)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AttemptStore) validate() error {
|
||||
if s == nil {
|
||||
return errors.New("personal event: nil subscription attempt store")
|
||||
}
|
||||
if s.workDir == "" {
|
||||
return errors.New("personal event: subscription attempt work directory is required")
|
||||
}
|
||||
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
|
||||
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
|
||||
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
|
||||
return errors.New("personal event: subscription attempt store is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
|
||||
if len(specs) == 0 {
|
||||
return nil, errors.New("personal event: at least one subscription attempt is required")
|
||||
}
|
||||
out := make([]AttemptSpec, 0, len(specs))
|
||||
seen := make(map[string]struct{}, len(specs))
|
||||
for _, spec := range specs {
|
||||
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
|
||||
spec.EventKey = strings.TrimSpace(spec.EventKey)
|
||||
if !validAttemptFingerprint(spec.Fingerprint) {
|
||||
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
|
||||
}
|
||||
if len(spec.EventKey) > attemptMaxFieldLength {
|
||||
return nil, errors.New("personal event: subscription attempt event key is too long")
|
||||
}
|
||||
if _, ok := seen[spec.Fingerprint]; ok {
|
||||
continue
|
||||
}
|
||||
seen[spec.Fingerprint] = struct{}{}
|
||||
out = append(out, spec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeSucceededFingerprints(
|
||||
claim *AttemptClaim,
|
||||
succeeded []string,
|
||||
failed string,
|
||||
) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(succeeded))
|
||||
for _, fingerprint := range succeeded {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == failed {
|
||||
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
|
||||
}
|
||||
if !containsFingerprint(claim.Fingerprints, fingerprint) {
|
||||
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
|
||||
}
|
||||
out[fingerprint] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateAttemptClaim(claim *AttemptClaim) error {
|
||||
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
|
||||
len(claim.Fingerprints) == 0 || claim.previous == nil {
|
||||
return errors.New("personal event: invalid subscription attempt claim")
|
||||
}
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
if !validAttemptFingerprint(fingerprint) {
|
||||
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
|
||||
}
|
||||
if _, ok := claim.previous[fingerprint]; !ok {
|
||||
return errors.New("personal event: subscription attempt claim is incomplete")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsFingerprint(fingerprints []string, target string) bool {
|
||||
for _, fingerprint := range fingerprints {
|
||||
if fingerprint == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validAttemptFingerprint(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == sha256.Size
|
||||
}
|
||||
|
||||
func validRetryability(value Retryability) bool {
|
||||
switch value {
|
||||
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func attemptFailureDelay(
|
||||
fingerprint string,
|
||||
count int,
|
||||
failure AttemptFailure,
|
||||
) (time.Duration, AttemptState) {
|
||||
if failure.Retryability == RetryabilityNonRetryable {
|
||||
return attemptTerminalHold, AttemptStateTerminalHold
|
||||
}
|
||||
base := attemptBackoff(count)
|
||||
delay := base + deterministicAttemptJitter(fingerprint, count, base)
|
||||
if failure.RetryAfter > delay {
|
||||
delay = failure.RetryAfter
|
||||
}
|
||||
return delay, AttemptStateCooldown
|
||||
}
|
||||
|
||||
func attemptBackoff(count int) time.Duration {
|
||||
switch count {
|
||||
case 1:
|
||||
return 30 * time.Second
|
||||
case 2:
|
||||
return 2 * time.Minute
|
||||
case 3:
|
||||
return 10 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
|
||||
// 0..2000 basis points, inclusive (0%..20%).
|
||||
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
|
||||
return time.Duration(int64(base) * int64(basisPoints) / 10000)
|
||||
}
|
||||
|
||||
func boundedAttemptField(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > attemptMaxFieldLength {
|
||||
return value[:attemptMaxFieldLength]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
|
||||
if previous.present {
|
||||
state.Records[fingerprint] = previous.record
|
||||
return
|
||||
}
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
|
||||
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
|
||||
for _, fingerprint := range claim.Fingerprints {
|
||||
record, ok := state.Records[fingerprint]
|
||||
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
|
||||
var next time.Time
|
||||
retryability := record.Retryability
|
||||
switch record.State {
|
||||
case AttemptStateInFlight:
|
||||
if !record.LeaseUntil.After(now) {
|
||||
return nil
|
||||
}
|
||||
next = record.LeaseUntil
|
||||
retryability = RetryabilityUnknown
|
||||
case AttemptStateCooldown, AttemptStateTerminalHold:
|
||||
if !record.NextAllowedAt.After(now) {
|
||||
return nil
|
||||
}
|
||||
next = record.NextAllowedAt
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &AttemptBlockedError{
|
||||
Fingerprint: record.Fingerprint,
|
||||
EventKey: record.EventKey,
|
||||
State: record.State,
|
||||
Retryability: retryability,
|
||||
RetryAfter: next.Sub(now),
|
||||
NextAllowedAt: next,
|
||||
FailureCount: record.FailureCount,
|
||||
ErrorCode: record.ErrorCode,
|
||||
TraceID: record.TraceID,
|
||||
}
|
||||
}
|
||||
|
||||
type attemptStateFile struct {
|
||||
Version int `json:"version"`
|
||||
Records map[string]attemptRecord `json:"records"`
|
||||
}
|
||||
|
||||
type attemptRecord struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
EventKey string `json:"event_key,omitempty"`
|
||||
State AttemptState `json:"state"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
LeaseUntil time.Time `json:"lease_until,omitempty"`
|
||||
FailureCount int `json:"failure_count,omitempty"`
|
||||
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
|
||||
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
|
||||
Retryability Retryability `json:"retryability,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *AttemptStore) load() (*attemptStateFile, error) {
|
||||
path := filepath.Join(s.workDir, AttemptStateFileName)
|
||||
data, err := s.readFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return newAttemptStateFile(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
|
||||
}
|
||||
var state attemptStateFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
|
||||
}
|
||||
if state.Version != attemptStateVersion {
|
||||
return nil, fmt.Errorf(
|
||||
"personal event: unsupported subscription attempt state version %d",
|
||||
state.Version,
|
||||
)
|
||||
}
|
||||
if state.Records == nil {
|
||||
return nil, errors.New("personal event: subscription attempt state has no records map")
|
||||
}
|
||||
for fingerprint, record := range state.Records {
|
||||
if err := validateAttemptRecord(fingerprint, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func newAttemptStateFile() *attemptStateFile {
|
||||
return &attemptStateFile{
|
||||
Version: attemptStateVersion,
|
||||
Records: make(map[string]attemptRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
|
||||
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
|
||||
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
|
||||
}
|
||||
if len(record.EventKey) > attemptMaxFieldLength ||
|
||||
len(record.AttemptID) > attemptMaxFieldLength ||
|
||||
len(record.ErrorCode) > attemptMaxFieldLength ||
|
||||
len(record.TraceID) > attemptMaxFieldLength {
|
||||
return errors.New("personal event: subscription attempt state contains an oversized field")
|
||||
}
|
||||
if record.FailureCount < 0 {
|
||||
return errors.New("personal event: subscription attempt state contains a negative failure count")
|
||||
}
|
||||
if record.Retryability != "" && !validRetryability(record.Retryability) {
|
||||
return errors.New("personal event: subscription attempt state contains invalid retryability")
|
||||
}
|
||||
switch record.State {
|
||||
case AttemptStateInFlight:
|
||||
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
|
||||
return errors.New("personal event: in-flight subscription attempt state is incomplete")
|
||||
}
|
||||
case AttemptStateCooldown:
|
||||
if record.NextAllowedAt.IsZero() ||
|
||||
(record.Retryability != RetryabilityUnknown &&
|
||||
record.Retryability != RetryabilityRetryable) {
|
||||
return errors.New("personal event: cooldown subscription attempt state is invalid")
|
||||
}
|
||||
case AttemptStateTerminalHold:
|
||||
if record.NextAllowedAt.IsZero() ||
|
||||
record.Retryability != RetryabilityNonRetryable {
|
||||
return errors.New("personal event: terminal subscription attempt state is invalid")
|
||||
}
|
||||
default:
|
||||
return errors.New("personal event: subscription attempt state contains an invalid state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
|
||||
for fingerprint, record := range state.Records {
|
||||
switch {
|
||||
case !record.LastFailureAt.IsZero() &&
|
||||
!now.Before(record.LastFailureAt) &&
|
||||
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
|
||||
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
|
||||
((record.State == AttemptStateCooldown ||
|
||||
record.State == AttemptStateTerminalHold) &&
|
||||
!record.NextAllowedAt.After(now))):
|
||||
delete(state.Records, fingerprint)
|
||||
case record.State == AttemptStateInFlight &&
|
||||
record.LastFailureAt.IsZero() &&
|
||||
!record.LeaseUntil.IsZero() &&
|
||||
!now.Before(record.LeaseUntil) &&
|
||||
now.Sub(record.LeaseUntil) >= attemptFailureReset:
|
||||
delete(state.Records, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AttemptStore) write(state *attemptStateFile) error {
|
||||
if state == nil {
|
||||
return errors.New("personal event: nil subscription attempt state")
|
||||
}
|
||||
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(s.workDir, AttemptStateFileName)
|
||||
if len(state.Records) == 0 {
|
||||
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(state.Records))
|
||||
for fingerprint := range state.Records {
|
||||
keys = append(keys, fingerprint)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ordered := make(map[string]attemptRecord, len(keys))
|
||||
for _, fingerprint := range keys {
|
||||
ordered[fingerprint] = state.Records[fingerprint]
|
||||
}
|
||||
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
|
||||
data, err := s.marshal(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp := path + ".tmp"
|
||||
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
|
||||
}
|
||||
if err := s.chmod(tmp, config.FilePerm); err != nil {
|
||||
_ = s.remove(tmp)
|
||||
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
|
||||
}
|
||||
if err := s.rename(tmp, path); err != nil {
|
||||
_ = s.remove(tmp)
|
||||
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AttemptStore) withLock(fn func() error) error {
|
||||
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
||||
}
|
||||
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
|
||||
deadline := s.now().Add(attemptLockWaitTimeout)
|
||||
for {
|
||||
held, err := s.tryAcquire(lockPath)
|
||||
if err == nil {
|
||||
if err := s.chmod(lockPath, config.FilePerm); err != nil {
|
||||
_ = held.Close()
|
||||
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
|
||||
}
|
||||
defer held.Close()
|
||||
return fn()
|
||||
}
|
||||
if !errors.Is(err, eventlock.ErrBusy) {
|
||||
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
|
||||
}
|
||||
remaining := deadline.Sub(s.now())
|
||||
if remaining <= 0 {
|
||||
return fmt.Errorf(
|
||||
"personal event: timed out waiting for subscription attempt lock after %s",
|
||||
attemptLockWaitTimeout,
|
||||
)
|
||||
}
|
||||
delay := attemptLockRetryDelay
|
||||
if remaining < delay {
|
||||
delay = remaining
|
||||
}
|
||||
s.sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
func newAttemptID() (string, error) {
|
||||
return rand.Text(), nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,8 @@ type Client struct {
|
||||
HTTPClient *http.Client
|
||||
Identity Identity
|
||||
AccessTokenProvider func(context.Context) (string, error)
|
||||
ClientVersion string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type CreateSubscriptionRequest struct {
|
||||
@@ -141,10 +144,14 @@ func (s dwsSubscription) toSubscription() Subscription {
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
@@ -178,18 +185,6 @@ func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionR
|
||||
}
|
||||
var sub Subscription
|
||||
if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" {
|
||||
return &Subscription{
|
||||
SubscribeID: subID,
|
||||
EventKey: req.EventKey,
|
||||
RuleType: req.RuleType,
|
||||
Status: "active",
|
||||
SourceID: c.Identity.SourceID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if sub.EventKey == "" {
|
||||
@@ -378,14 +373,20 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
return fmt.Errorf("personal event: read response: %w", err)
|
||||
}
|
||||
responseLog := sanitizeLogPayload(data)
|
||||
responseID := firstNonEmpty(responseRequestID(data), responseHeaderRequestID(resp.Header))
|
||||
responseTrace := firstNonEmpty(responseBodyTraceID(data), responseTraceID(resp.Header))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if apiErr := decodeAPIError(data); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data))
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, responseID, responseTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
return fmt.Errorf("personal event: HTTP %d", resp.StatusCode)
|
||||
apiErr := withHTTPResponseDetails(&APIError{
|
||||
Code: fmt.Sprintf("HTTP_%d", resp.StatusCode),
|
||||
Message: firstNonEmpty(http.StatusText(resp.StatusCode), "HTTP request failed"),
|
||||
}, method, path, resp, responseID, responseTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
|
||||
@@ -393,23 +394,25 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
}
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
|
||||
envID := firstNonEmpty(env.requestID(), responseID)
|
||||
envTrace := firstNonEmpty(env.traceID(), responseTrace)
|
||||
if env.Success == nil {
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
if env.Success != nil && !*env.Success {
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
|
||||
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
|
||||
return apiErr
|
||||
}
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
|
||||
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
|
||||
return errors.New("personal event: request failed")
|
||||
}
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
|
||||
if env.Result == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -419,10 +422,10 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
||||
return decodeResult(env.Result, out)
|
||||
}
|
||||
if out == nil {
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
|
||||
return nil
|
||||
}
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
|
||||
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
@@ -451,25 +454,58 @@ func (c *Client) decorate(req *http.Request, accessToken string) {
|
||||
if c.Identity.CorpID != "" {
|
||||
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
|
||||
}
|
||||
if version := strings.TrimSpace(c.ClientVersion); version != "" {
|
||||
req.Header.Set("X-Cli-Version", version)
|
||||
}
|
||||
if userAgent := strings.TrimSpace(c.UserAgent); userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
RequestID2 string `json:"requestId,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *APIError `json:"error"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Success *bool `json:"success"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
RequestID2 string `json:"requestId,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
TraceID2 string `json:"traceId,omitempty"`
|
||||
Arguments []json.RawMessage `json:"arguments,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *APIError `json:"error"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
|
||||
RetryAfterSecondsCamel *int64 `json:"retryAfterSeconds,omitempty"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
|
||||
NextRetryAtCamel *time.Time `json:"nextRetryAt,omitempty"`
|
||||
}
|
||||
|
||||
func (e responseEnvelope) apiError() *APIError {
|
||||
if e.Error != nil {
|
||||
if e.Error.Retryable == nil {
|
||||
e.Error.Retryable = e.Retryable
|
||||
}
|
||||
if e.Error.RetryAfterSeconds == nil {
|
||||
e.Error.RetryAfterSeconds = firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel)
|
||||
}
|
||||
if e.Error.NextRetryAt == nil {
|
||||
e.Error.NextRetryAt = firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel)
|
||||
}
|
||||
if e.Error.TraceID == "" {
|
||||
e.Error.TraceID = e.traceID()
|
||||
}
|
||||
return e.Error
|
||||
}
|
||||
if e.ErrorCode != "" || e.ErrorMsg != "" {
|
||||
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
|
||||
return &APIError{
|
||||
Code: e.ErrorCode,
|
||||
Message: e.ErrorMsg,
|
||||
Retryable: e.Retryable,
|
||||
RetryAfterSeconds: firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel),
|
||||
NextRetryAt: firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel),
|
||||
TraceID: e.traceID(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -478,6 +514,10 @@ func (e responseEnvelope) requestID() string {
|
||||
return firstNonEmpty(e.RequestID, e.RequestID2)
|
||||
}
|
||||
|
||||
func (e responseEnvelope) traceID() string {
|
||||
return firstNonEmpty(e.TraceID, e.TraceID2, firstArgumentString(e.Arguments))
|
||||
}
|
||||
|
||||
func decodeResult(raw json.RawMessage, out any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
@@ -511,11 +551,8 @@ func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
|
||||
func decodeAPIError(data []byte) *APIError {
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
if env.Error != nil {
|
||||
return env.Error
|
||||
}
|
||||
if env.ErrorCode != "" || env.ErrorMsg != "" {
|
||||
return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg}
|
||||
if apiErr := env.apiError(); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
var apiErr APIError
|
||||
@@ -533,12 +570,24 @@ func responseRequestID(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func responseBodyTraceID(data []byte) string {
|
||||
var env responseEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
if env.Error != nil && strings.TrimSpace(env.Error.TraceID) != "" {
|
||||
return strings.TrimSpace(env.Error.TraceID)
|
||||
}
|
||||
return env.traceID()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
|
||||
if apiErr == nil {
|
||||
return nil
|
||||
}
|
||||
apiErr.HTTPStatus = status
|
||||
if apiErr.Details == nil {
|
||||
apiErr.Details = make(map[string]any, 4)
|
||||
apiErr.Details = make(map[string]any, 8)
|
||||
}
|
||||
apiErr.Details["method"] = method
|
||||
apiErr.Details["path"] = path
|
||||
@@ -549,6 +598,91 @@ func withRequestDetails(apiErr *APIError, method, path string, status int, reque
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func withHTTPResponseDetails(apiErr *APIError, method, path string, resp *http.Response, requestID, traceID string) *APIError {
|
||||
if apiErr == nil {
|
||||
return nil
|
||||
}
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
traceID = firstNonEmpty(apiErr.TraceID, traceID, responseTraceID(resp.Header))
|
||||
}
|
||||
apiErr = withRequestDetails(apiErr, method, path, status, requestID)
|
||||
if apiErr.TraceID == "" {
|
||||
apiErr.TraceID = strings.TrimSpace(traceID)
|
||||
}
|
||||
if resp == nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||
if retryAfter == "" {
|
||||
return apiErr
|
||||
}
|
||||
apiErr.Details["retry_after"] = retryAfter
|
||||
if apiErr.RetryAfterSeconds == nil && apiErr.NextRetryAt == nil {
|
||||
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 {
|
||||
apiErr.RetryAfterSeconds = &seconds
|
||||
} else if next, err := http.ParseTime(retryAfter); err == nil {
|
||||
next = next.UTC()
|
||||
apiErr.NextRetryAt = &next
|
||||
}
|
||||
}
|
||||
if apiErr.RetryAfterSeconds != nil {
|
||||
apiErr.Details["retry_after_seconds"] = *apiErr.RetryAfterSeconds
|
||||
}
|
||||
if apiErr.NextRetryAt != nil {
|
||||
apiErr.Details["next_retry_at"] = apiErr.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func responseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{"X-Trace-Id", "X-Dingtalk-Trace-Id"} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func responseHeaderRequestID(headers http.Header) string {
|
||||
return strings.TrimSpace(headers.Get("X-Request-Id"))
|
||||
}
|
||||
|
||||
func firstArgumentString(arguments []json.RawMessage) string {
|
||||
if len(arguments) == 0 {
|
||||
return ""
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(arguments[0], &value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func firstInt64Pointer(values ...*int64) *int64 {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstTimePointer(values ...*time.Time) *time.Time {
|
||||
for _, value := range values {
|
||||
if value == nil || value.IsZero() {
|
||||
continue
|
||||
}
|
||||
copy := value.UTC()
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
|
||||
attrs := []any{
|
||||
"method", method,
|
||||
@@ -568,6 +702,9 @@ func logControlRequest(message, method, path string, q url.Values, status int, r
|
||||
attrs = append(attrs, "request_id", requestID)
|
||||
}
|
||||
if apiErr != nil {
|
||||
if apiErr.TraceID != "" {
|
||||
attrs = append(attrs, "trace_id", apiErr.TraceID)
|
||||
}
|
||||
if apiErr.Code != "" {
|
||||
attrs = append(attrs, "error_code", apiErr.Code)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
|
||||
@@ -282,6 +283,12 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
|
||||
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
|
||||
t.Fatalf("details = %#v", apiErr.Details)
|
||||
}
|
||||
if apiErr.Retryable != nil {
|
||||
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
|
||||
}
|
||||
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
|
||||
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
|
||||
}
|
||||
out := logs.String()
|
||||
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
|
||||
if !strings.Contains(out, want) {
|
||||
@@ -290,6 +297,278 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
|
||||
nextRetryAt := "2026-07-30T04:05:06Z"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
|
||||
t.Fatalf("X-Cli-Version = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"errorCode": "RATE_LIMITED",
|
||||
"errorMsg": "slow down",
|
||||
"retryable": false,
|
||||
"retryAfterSeconds": 30,
|
||||
"nextRetryAt": nextRetryAt,
|
||||
"arguments": []string{"portal-trace-1"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
c.ClientVersion = "1.2.3"
|
||||
c.UserAgent = "dws-cli/1.2.3"
|
||||
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if apiErr.Retryable == nil || *apiErr.Retryable {
|
||||
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
|
||||
}
|
||||
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
|
||||
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
|
||||
}
|
||||
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
|
||||
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
|
||||
}
|
||||
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
|
||||
}
|
||||
if _, exists := apiErr.Details["request_id"]; exists {
|
||||
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
headers http.Header
|
||||
wantTraceID string
|
||||
wantRequestID string
|
||||
}{
|
||||
{
|
||||
name: "nested trace wins",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"traceId": "top-trace",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{
|
||||
"code": "SYSTEM_ERROR",
|
||||
"message": "failed",
|
||||
"trace_id": "nested-trace",
|
||||
},
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Trace-Id": []string{"header-trace"},
|
||||
"X-Request-Id": []string{"header-request"},
|
||||
},
|
||||
wantTraceID: "nested-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "top-level trace wins over arguments and header",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"traceId": "top-trace",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
|
||||
wantTraceID: "top-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "portal argument wins over header",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"requestId": "body-request",
|
||||
"arguments": []string{"portal-trace"},
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
|
||||
wantTraceID: "portal-trace",
|
||||
wantRequestID: "body-request",
|
||||
},
|
||||
{
|
||||
name: "headers are independent fallbacks",
|
||||
body: map[string]any{
|
||||
"success": false,
|
||||
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Trace-Id": []string{"header-trace"},
|
||||
"X-Request-Id": []string{"header-request"},
|
||||
},
|
||||
wantTraceID: "header-trace",
|
||||
wantRequestID: "header-request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
for key, values := range test.headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(test.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
})
|
||||
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if apiErr.TraceID != test.wantTraceID {
|
||||
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
|
||||
}
|
||||
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
|
||||
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
retryAfter string
|
||||
wantSeconds int64
|
||||
wantNextRetryAt string
|
||||
}{
|
||||
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
|
||||
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Retry-After", tt.retryAfter)
|
||||
w.Header().Set("X-Trace-Id", "header-trace")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %v, want *APIError", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
|
||||
}
|
||||
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
|
||||
t.Fatalf("API error = %#v", apiErr)
|
||||
}
|
||||
if apiErr.Retryable != nil {
|
||||
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
|
||||
}
|
||||
if tt.wantSeconds > 0 {
|
||||
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
|
||||
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
|
||||
}
|
||||
}
|
||||
if tt.wantNextRetryAt != "" {
|
||||
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
|
||||
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
|
||||
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
|
||||
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
|
||||
}
|
||||
|
||||
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
|
||||
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
|
||||
if got != apiErr {
|
||||
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
|
||||
}
|
||||
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
|
||||
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
|
||||
}
|
||||
if got.Details["request_id"] != "request-1" {
|
||||
t.Fatalf("details = %#v", got.Details)
|
||||
}
|
||||
|
||||
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
|
||||
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
"SYSTEM_ERROR",
|
||||
"DUP",
|
||||
"DUPLICATE_SUBSCRIPTION",
|
||||
"SUBSCRIPTION_ALREADY_EXISTS",
|
||||
"SUBSCRIPTION_ALREADY_EXIST",
|
||||
"ALREADY_SUBSCRIBED",
|
||||
"DUPLICATE",
|
||||
} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": "registration failed",
|
||||
"details": map[string]any{"subscribe_id": "pending-sub"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
|
||||
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
|
||||
EventKey: EventMention,
|
||||
RuleType: "at",
|
||||
RuleParam: map[string]any{},
|
||||
})
|
||||
if err == nil || sub != nil {
|
||||
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != code {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
|
||||
|
||||
@@ -219,8 +219,8 @@ func TestCrossPlatformCoveragePersonalClientHelpersAndOperations(t *testing.T) {
|
||||
}
|
||||
base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
|
||||
created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
|
||||
if err != nil || created.SubscribeID != "existing" {
|
||||
t.Fatalf("duplicate create = %#v, %v", created, err)
|
||||
if err == nil || created != nil {
|
||||
t.Fatalf("duplicate error create = %#v, %v", created, err)
|
||||
}
|
||||
request := base.buildCreateRequest(CreateSubscriptionRequest{
|
||||
EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -651,6 +652,37 @@ func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type devAppFailingCountRunner struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls++
|
||||
return executor.Result{Invocation: invocation}, r.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
|
||||
wantErr := stderrors.New("event subscription failed")
|
||||
runner := &devAppFailingCountRunner{err: wantErr}
|
||||
root := newDevAppTestRoot(runner)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"dev", "app", "event", "subscribe",
|
||||
"--unified-app-id", "u-1",
|
||||
"--event-codes", "a,b",
|
||||
"--yes",
|
||||
})
|
||||
|
||||
if err := root.Execute(); !stderrors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls = %d, want 1", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -292,6 +292,43 @@ func TestCallToolUsesJSONRPCMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCallToolDevAppEventSubscribeRetriesAreBounded(t *testing.T) {
|
||||
attempts := 0
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
client := NewClient(httpClient)
|
||||
client.sleep = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
_, err := client.CallTool(
|
||||
context.Background(),
|
||||
"https://mcp.example.test/dws",
|
||||
"subscribe_dev_app_events",
|
||||
map[string]any{
|
||||
"unifiedAppId": "u-1",
|
||||
"eventCodes": []string{"event-a", "event-b"},
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() succeeded after repeated HTTP 503 responses")
|
||||
}
|
||||
wantAttempts := client.MaxRetries + 1
|
||||
if attempts != wantAttempts {
|
||||
t.Fatalf("HTTP attempts = %d, want configured bound %d", attempts, wantAttempts)
|
||||
}
|
||||
if attempts > 2 {
|
||||
t.Fatalf("dev app event subscribe HTTP attempts = %d, want at most 2 by default", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolInjectsAuthHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -171,6 +171,26 @@ dws event stop --all --yes
|
||||
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先预览 `dws event stop <subscribe_id> --dry-run`、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
|
||||
- 一个 consume 可监听多个兼容 event key,每个事件仍有独立订阅和 consumer,共用一个 bus、远程连接及输出。`event stop <subscribe_id>` 只移除目标事件,最后一个被移除后进程退出。
|
||||
|
||||
## 订阅创建失败与重试预算
|
||||
|
||||
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
|
||||
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
|
||||
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
|
||||
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
|
||||
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
|
||||
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
|
||||
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
|
||||
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
|
||||
|
||||
### 本地保护状态运维
|
||||
|
||||
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
|
||||
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
|
||||
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
|
||||
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
|
||||
|
||||
## Output parsing
|
||||
|
||||
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
|
||||
|
||||
@@ -74,6 +74,26 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
|
||||
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
|
||||
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
|
||||
|
||||
## 订阅创建失败与重试预算
|
||||
|
||||
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
|
||||
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
|
||||
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
|
||||
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
|
||||
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
|
||||
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
|
||||
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
|
||||
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
|
||||
|
||||
### 本地保护状态运维
|
||||
|
||||
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
|
||||
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
|
||||
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
|
||||
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
|
||||
@@ -369,6 +369,26 @@ dws event stop --all --yes
|
||||
|
||||
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。多事件进程中,停止一个 `subscribe_id` 只移除对应逻辑 consumer,其余事件继续监听;最后一个被移除时进程退出。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
|
||||
|
||||
## 订阅创建失败与重试预算
|
||||
|
||||
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
|
||||
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
|
||||
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
|
||||
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
|
||||
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
|
||||
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
|
||||
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
|
||||
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
|
||||
|
||||
### 本地保护状态运维
|
||||
|
||||
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
|
||||
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
|
||||
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
|
||||
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- 没有输出:单事件确认 stderr 已出现 `ready event_key=...`;多事件确认已出现 `ready event_count=...`,不要把某条 `subscription` 行误认为整体就绪。
|
||||
|
||||
@@ -130,6 +130,131 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventSkillPinsSubscriptionRetryOrchestrationContract(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller(0) failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
paths := []string{
|
||||
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
|
||||
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
|
||||
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
|
||||
filepath.Join(root, "docs", "event-subprocess-contract.md"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
text := string(content)
|
||||
normalizedText := strings.Join(strings.Fields(text), " ")
|
||||
for _, required := range []string{
|
||||
"16",
|
||||
"--profile",
|
||||
"Agent/host",
|
||||
"0/2/1",
|
||||
"retryable=false",
|
||||
"max_additional_attempts=0",
|
||||
"retryable=true",
|
||||
"max_additional_attempts=2",
|
||||
"retryable=unknown",
|
||||
"max_additional_attempts=1",
|
||||
"retry_after_seconds",
|
||||
"next_retry_at",
|
||||
"in_flight",
|
||||
"cooldown",
|
||||
"terminal_hold",
|
||||
"subscribe_id",
|
||||
"trace_id",
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Errorf("%s missing subscription retry orchestration contract %q", path, required)
|
||||
}
|
||||
}
|
||||
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
|
||||
for _, required := range []string{
|
||||
"not a CLI-enforced persisted total-attempt cap",
|
||||
"performs no in-process automatic retry",
|
||||
"does not persist or enforce the Agent/host attempt count",
|
||||
} {
|
||||
if !strings.Contains(normalizedText, required) {
|
||||
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, required := range []string{
|
||||
"不是 CLI 持久化硬总次数上限",
|
||||
"进程内不会自动重试",
|
||||
"不持久化或计算跨调用的 Agent/host 尝试次数",
|
||||
} {
|
||||
if !strings.Contains(normalizedText, required) {
|
||||
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventSkillDocumentsSubscriptionGuardOperations(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller(0) failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
paths := []string{
|
||||
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
|
||||
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
|
||||
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
|
||||
filepath.Join(root, "docs", "event-subprocess-contract.md"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
text := string(content)
|
||||
normalizedText := strings.Join(strings.Fields(text), " ")
|
||||
for _, required := range []string{
|
||||
"~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json",
|
||||
"DWS_CONFIG_DIR",
|
||||
"personal_subscription_attempts.json",
|
||||
"personal_subscription_attempts.lock",
|
||||
"0700",
|
||||
"0600",
|
||||
"24h",
|
||||
"1h",
|
||||
"terminal_hold",
|
||||
"next_retry_at",
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Errorf("%s missing subscription guard operations %q", path, required)
|
||||
}
|
||||
}
|
||||
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
|
||||
for _, required := range []string{
|
||||
"Delete only",
|
||||
"never the lock file",
|
||||
"every protection record for that identity",
|
||||
} {
|
||||
if !strings.Contains(normalizedText, required) {
|
||||
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, required := range []string{
|
||||
"只删除 `personal_subscription_attempts.json`",
|
||||
"不要删除 lock 文件",
|
||||
"该 identity 的全部保护记录",
|
||||
} {
|
||||
if !strings.Contains(normalizedText, required) {
|
||||
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
|
||||
Reference in new issue
Block a user