orama/core/pkg/push/dispatcher.go
anonpenguin23 3b8139802c feat: APNs silent-drop guard + persistent-WS mid-session JWT refresh
#348 - APNs silent-drop guard
Apple's APNs silently returns HTTP 200 for pushes with no visible
content (no title, no body, no badge, no sound, no
content-available=1) and then drops them — which looked to the WASM
caller like a successful delivery. Now rejected up-front with the new
push.ErrEmptyContent sentinel, and the APNs provider returns the
structured push.PushError shape (HTTPStatus, Reason, Unregistered,
Wrapped) so the dispatcher can branch on Unregistered to remove dead
tokens automatically. Legacy ErrDeviceUnregistered sentinel is
preserved for errors.Is compatibility (wrapped inside PushError).

Always logs APNs HTTP response (status, reason, apns_id, token prefix)
so future silent-drop classes show up in operator logs.

content-available is also now correctly mapped from snake_case
Data["content_available"] (any truthy variant) into Apple's
canonical "content-available": 1 inside the aps dictionary.

#321 - mid-session JWT refresh on persistent WS
Long-lived persistent WS connections used to have to close+reconnect
when the JWT rolled — losing per-instance state, message queues, and
subscriptions. The handler now accepts an "auth.refresh" control
frame: client sends the new token, the gateway re-verifies it via
the new JWTVerifier interface, updates the per-instance invCtx
in-place (persistent.Instance.UpdateInvCtx), and acks. No close, no
state loss.

JWTVerifier is optional — handlers set it via SetJWTVerifier at
gateway init. When unwired the handler nack's with a "not supported
on this gateway" response and clients fall back to the old
close+reconnect path, so older deploys don't break.

Other:
- push/dispatcher.go: SendToUserDetailed returns per-device PushError
  shape so callers can act on Unregistered / HTTPStatus / Reason.
- serverless/hostfunctions/push.go: WASM host functions for the new
  detailed-error shape.
- serverless/persistent/instance.go: UpdateInvCtx mid-session.

Tests:
- ws_persistent_control_test.go: auth.refresh ack/nack paths.
- apns_test.go: empty-content rejection, PushError shape on 410 +
  generic non-200, content-available mapping.
- dispatcher_detailed_test.go: SendToUserDetailed result shape.
- instance_update_invctx_test.go: invCtx update is per-instance, not
  cross-tenant.

VERSION bumped to 0.122.27.
2026-05-19 18:19:21 +03:00

161 lines
4.8 KiB
Go

package push
import (
"context"
"errors"
"fmt"
"sync"
"go.uber.org/zap"
)
// PushDispatcher routes push messages to the matching provider for each
// of a user's registered devices.
type PushDispatcher struct {
mu sync.RWMutex
providers map[string]PushProvider
devices PushDeviceStore
logger *zap.Logger
}
// New creates a dispatcher with the given device store. Register
// providers before sending.
func New(devices PushDeviceStore, logger *zap.Logger) *PushDispatcher {
if logger == nil {
logger = zap.NewNop()
}
return &PushDispatcher{
providers: map[string]PushProvider{},
devices: devices,
logger: logger.Named("push"),
}
}
// Register makes a provider available to dispatch. Calling Register with
// the same name twice replaces the previous provider — useful in tests.
func (d *PushDispatcher) Register(p PushProvider) {
d.mu.Lock()
defer d.mu.Unlock()
d.providers[p.Name()] = p
}
// Provider returns the registered provider by name, or nil.
func (d *PushDispatcher) Provider(name string) PushProvider {
d.mu.RLock()
defer d.mu.RUnlock()
return d.providers[name]
}
// SendToUser fans out the message to every registered device for the
// user. Each provider failure is logged but does not stop subsequent
// devices. Returns the first encountered error (if any) so callers can
// surface a partial-failure signal.
//
// SendToUser returns nil if the user has no registered devices — that
// is normal, not an error.
//
// Callers wanting per-device outcomes should use SendToUserDetailed
// (bugboard #348 — back-compat preserved on this method).
func (d *PushDispatcher) SendToUser(
ctx context.Context,
namespace, userID string,
msg PushMessage,
) error {
res, err := d.SendToUserDetailed(ctx, namespace, userID, msg)
if err != nil {
return err
}
// Preserve the legacy contract: return the first per-device error
// with the full error chain intact (sentinels like ErrUnknownProvider
// and ErrDeviceUnregistered are reachable via errors.Is on the result).
for _, r := range res.Results {
if !r.Success && r.err != nil {
return r.err
}
}
return nil
}
// SendToUserDetailed dispatches to every registered device for the user
// and returns a per-device outcome. Unlike SendToUser (which collapses
// to a single error), this surfaces every device's HTTP status / reason
// so the caller can react granularly (delete on Unregistered, retry on
// 5xx, log unknowns, etc.).
//
// Used by the `oh.PushSendV2` WASM host function so WASM callers can
// auto-clean stale tokens and surface real failures (bugboard #348).
//
// Returns (nil, err) only on setup failures (device-store query failed,
// etc.). A user with zero devices returns
// (&SendDetailedResult{Ok: true, DevicesAttempted: 0}, nil).
func (d *PushDispatcher) SendToUserDetailed(
ctx context.Context,
namespace, userID string,
msg PushMessage,
) (*SendDetailedResult, error) {
devs, err := d.devices.ListForUser(ctx, namespace, userID)
if err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
out := &SendDetailedResult{
Ok: true, // flipped to false on the first failure
DevicesAttempted: len(devs),
Results: make([]DeviceSendResult, 0, len(devs)),
}
if len(devs) == 0 {
return out, nil
}
for _, dev := range devs {
r := DeviceSendResult{DeviceID: dev.DeviceID, Provider: dev.Provider}
d.mu.RLock()
p, ok := d.providers[dev.Provider]
d.mu.RUnlock()
if !ok {
r.Success = false
r.Message = fmt.Sprintf("push: unknown provider %q (device not dispatched)", dev.Provider)
// Preserve the sentinel error chain so legacy callers using
// errors.Is(err, ErrUnknownProvider) on the SendToUser
// return value keep working.
r.err = fmt.Errorf("%w: %s", ErrUnknownProvider, dev.Provider)
d.logger.Warn("push: dropping device with unregistered provider",
zap.String("provider", dev.Provider),
zap.String("device_id", dev.DeviceID),
)
out.Ok = false
out.Results = append(out.Results, r)
continue
}
m := msg
m.DeviceToken = dev.Token
if sendErr := p.Send(ctx, m); sendErr != nil {
r.Success = false
r.err = sendErr // preserve full chain for errors.Is/As
// Extract structured info if the provider returned PushError.
var perr *PushError
if errors.As(sendErr, &perr) {
r.HTTPStatus = perr.HTTPStatus
r.Reason = perr.Reason
r.Message = perr.Message
r.Unregistered = perr.Unregistered
} else {
r.Message = sendErr.Error()
}
d.logger.Warn("push: provider send failed",
zap.String("provider", dev.Provider),
zap.String("device_id", dev.DeviceID),
zap.Int("http_status", r.HTTPStatus),
zap.String("reason", r.Reason),
zap.Bool("unregistered", r.Unregistered),
zap.Error(sendErr),
)
out.Ok = false
} else {
r.Success = true
out.DevicesSucceeded++
}
out.Results = append(out.Results, r)
}
return out, nil
}