Files
at-container-registry/pkg/appview/holdclient/tier_update.go
T
Evan JarrettandClaude Opus 5 12c55ed560 billing: make Stripe webhook delivery idempotent and retryable
Webhook delivery was neither idempotent nor order-safe, and every failure
returned 400, which Stripe does not retry. A transient DB or hold error
therefore dropped a subscription change silently and permanently.

  - New stripe_processed_events table: event_id as primary key dedups
    redelivery, and event_created per customer drops stale out-of-order
    deliveries.
  - HandleWebhook distinguishes ErrWebhookSignature (400, no retry) from
    ErrWebhookProcessing (500, Stripe redelivers). The event handlers
    return errors instead of swallowing them. ErrBillingDisabled maps to
    400: the route is mounted but billing is off, so redelivery can never
    succeed and Stripe should stop rather than retry to exhaustion.
  - Refuse to boot when billing is enabled with an empty
    STRIPE_WEBHOOK_SECRET. Stripe HMACs with the empty key, so an
    attacker can reproduce the signature and the endpoint is forgeable.
  - UpdateCrewTierOnAllHolds retries each hold (3 attempts, linear
    backoff, 5s per request) and returns a joined error so the webhook
    can fail and let Stripe redeliver.

The fan-out contacts holds concurrently rather than in sequence. Serially,
one unreachable hold burns the caller's entire 10s budget on its own
retries (3 x 5s plus backoff) and the holds after it are never contacted;
because Stripe redelivers in the same order, a persistently-down first
hold means the rest are never updated at all.

On the hold, the signature-validated sub claim is now the source of truth
for updateCrewTier: a mismatched body userDid is rejected with 403 rather
than retargeting the grant to another DID. "Not crew on this hold" is a
200 no-op, since the appview fans updates out to every managed hold and a
subscriber is not crew everywhere.

That no-op has to be told apart from a storage failure. GetCrewMember
collapsed both into one generic error, so a CAR-store failure read as
"not a member", answered 200, and let the appview record the event as
processed — losing the tier grant permanently, which is exactly the
failure mode this commit exists to prevent. Missing records now carry an
ErrCrewMemberNotFound sentinel, and anything else returns 500 so Stripe
redelivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

140 lines
4.7 KiB
Go

// Package holdclient provides client functions for the appview to call hold XRPC endpoints.
package holdclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// tierUpdateMaxAttempts bounds per-hold retries when pushing a tier update.
const tierUpdateMaxAttempts = 3
// tierUpdateHTTPClient bounds each hold request so a hung hold can't block the
// Stripe webhook indefinitely (http.DefaultClient has no timeout). The caller
// also imposes an overall context deadline across all holds and retries.
var tierUpdateHTTPClient = &http.Client{Timeout: 5 * time.Second}
// UpdateCrewTierOnHold calls io.atcr.hold.updateCrewTier on a specific hold.
// It signs a short-lived JWT with the appview's P-256 key and sends the tier update request.
func UpdateCrewTierOnHold(ctx context.Context, holdDID, holdURL, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error {
// Sign appview service token
token, err := auth.CreateAppviewServiceToken(privateKey, appviewDID, holdDID, userDID)
if err != nil {
return fmt.Errorf("failed to create appview token: %w", err)
}
// Build request body
body, err := json.Marshal(map[string]any{
"userDid": userDID,
"tierRank": tierRank,
})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// Build URL
url := strings.TrimSuffix(holdURL, "/") + atproto.HoldUpdateCrewTier
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := tierUpdateHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to call updateCrewTier on %s: %w", holdDID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("updateCrewTier on %s returned %d: %s", holdDID, resp.StatusCode, string(respBody))
}
return nil
}
// UpdateCrewTierOnAllHolds pushes a tier update to all managed holds, retrying
// each hold a bounded number of times. It returns a joined error covering every
// hold that could not be updated (after retries), so callers — notably the
// Stripe webhook — can fail the request and let Stripe redeliver. Returns nil
// only when every managed hold was updated successfully.
// Holds are contacted concurrently. Serially, one unreachable hold burns the
// caller's whole deadline on its own retries (3 attempts x 5s plus backoff) and
// the holds after it are never contacted at all — and since the caller retries
// in the same order, a persistently-down first hold means later holds are never
// updated. Fanning out gives every hold the same shot at the budget.
func UpdateCrewTierOnAllHolds(ctx context.Context, managedHolds []string, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error {
errs := make([]error, len(managedHolds))
var wg sync.WaitGroup
for i, holdDID := range managedHolds {
wg.Go(func() {
holdURL, err := atproto.ResolveHoldDIDToURL(ctx, holdDID)
if err != nil {
slog.Warn("Could not resolve hold DID to URL",
"holdDID", holdDID,
"error", err,
)
errs[i] = fmt.Errorf("resolve hold %s: %w", holdDID, err)
return
}
if err := updateCrewTierWithRetry(ctx, holdDID, holdURL, userDID, tierRank, privateKey, appviewDID); err != nil {
slog.Error("Failed to update crew tier on hold",
"holdDID", holdDID,
"userDID", userDID,
"tierRank", tierRank,
"error", err,
)
errs[i] = err
return
}
slog.Info("Updated crew tier on hold",
"holdDID", holdDID,
"userDID", userDID,
"tierRank", tierRank,
)
})
}
wg.Wait()
return errors.Join(errs...)
}
// updateCrewTierWithRetry calls UpdateCrewTierOnHold with bounded retries and a
// small linear backoff, aborting early if the context is cancelled.
func updateCrewTierWithRetry(ctx context.Context, holdDID, holdURL, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error {
var lastErr error
for attempt := 1; attempt <= tierUpdateMaxAttempts; attempt++ {
lastErr = UpdateCrewTierOnHold(ctx, holdDID, holdURL, userDID, tierRank, privateKey, appviewDID)
if lastErr == nil {
return nil
}
if attempt < tierUpdateMaxAttempts {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt) * 200 * time.Millisecond):
}
}
}
return fmt.Errorf("update crew tier on %s after %d attempts: %w", holdDID, tierUpdateMaxAttempts, lastErr)
}