mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-17 02:14:12 +00:00
#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.
161 lines
4.8 KiB
Go
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
|
|
}
|