Skip to content
quarry Artifact [a2588c81b0]
Login

Artifact [a2588c81b0]

Artifact a2588c81b0898cf56080af1d10d0a64897b0f97e69e19beda4b750de23572cab:


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// PushOutcomes is an OPTIONAL upgrade on NotifyPlane: how one triggered
// push went, read back from notify. Without it a test can only say
// "sent", which is what a push service that refused every device also
// looks like from here.
type PushOutcomes interface {
	PushOutcome(ctx context.Context, subscriber, txn string) (PushOutcome, error)
}

// PushOutcome is notify's record of one push: its status word and the
// error text that names each device that refused. Found is false until
// the push step has run.
type PushOutcome struct {
	Found  bool
	Status string
	Error  string
	// Devices is notify's per-device record, when it kept one.
	Devices []PushDeviceOutcome
}

// PushDeviceOutcome is one device's outcome on a test push: the push
// service's host (never the endpoint), delivered, failed, retired or
// gone, and the status and reason when it refused.
type PushDeviceOutcome struct {
	Host    string `json:"host"`
	Outcome string `json:"outcome"`
	Status  int    `json:"status,omitempty"`
	Reason  string `json:"reason,omitempty"`
}

// pushSummary is a push outcome in the words the test control says.
type pushSummary struct {
	// Outcome is delivered, partial, failed, or unknown (no answer yet).
	Outcome string
	// Delivered of Of devices took it; Of is 0 when notify did not say.
	Delivered, Of int
	// Why lists each refusal once, with a count: "2× 403 BadJwtToken".
	Why []string
}

var (
	deliveredRe = regexp.MustCompile(`delivered (\d+)/(\d+)`)
	statusRe    = regexp.MustCompile(`status (\d{3}): *(\{[^}]*\}|[^;)]*)`)
)

// summarizePush reads notify's status and error text. The text is
// notify's own sentence ("delivered 1/3: 2 permanent failure(s) (...)"),
// so this reads the parts that are stable — the delivered count, each
// HTTP status and its reason, timeouts — and keeps no URL: a push
// endpoint is a bearer secret for that device.
func summarizePush(o PushOutcome, devices int) pushSummary {
	if !o.Found {
		return pushSummary{Outcome: "unknown"}
	}
	s := pushSummary{}
	if m := deliveredRe.FindStringSubmatch(o.Error); m != nil {
		s.Delivered, _ = strconv.Atoi(m[1])
		s.Of, _ = strconv.Atoi(m[2])
	}
	counts := map[string]int{}
	var order []string
	note := func(why string) {
		if counts[why] == 0 {
			order = append(order, why)
		}
		counts[why]++
	}
	for _, m := range statusRe.FindAllStringSubmatch(o.Error, -1) {
		reason := strings.TrimSpace(m[2])
		var body struct {
			Reason string `json:"reason"`
		}
		if json.Unmarshal([]byte(reason), &body) == nil && body.Reason != "" {
			reason = body.Reason
		}
		if len(reason) > 60 {
			reason = reason[:60]
		}
		note(strings.TrimSpace(m[1] + " " + reason))
	}
	for i := 0; i < strings.Count(o.Error, "deadline exceeded"); i++ {
		note("timed out")
	}
	for _, w := range order {
		if counts[w] > 1 {
			s.Why = append(s.Why, fmt.Sprintf("%d× %s", counts[w], w))
		} else {
			s.Why = append(s.Why, w)
		}
	}
	switch {
	case o.Status == "error" || (s.Of > 0 && s.Delivered == 0):
		s.Outcome = "failed"
	case s.Of > 0 && s.Delivered < s.Of:
		s.Outcome = "partial"
	case o.Status == "sent":
		s.Outcome = "delivered"
		if s.Of == 0 {
			s.Delivered, s.Of = devices, devices
		}
	default:
		s.Outcome = "unknown"
	}
	return s
}

// awaitPush polls notify for one push's outcome, briefly: the push step
// runs asynchronously after the trigger, usually within a second.
func awaitPush(ctx context.Context, p PushOutcomes, who, txn string, wait time.Duration) PushOutcome {
	deadline := time.Now().Add(wait)
	for {
		o, err := p.PushOutcome(ctx, who, txn)
		if err == nil && o.Found {
			return o
		}
		if time.Now().After(deadline) || ctx.Err() != nil {
			return PushOutcome{}
		}
		time.Sleep(400 * time.Millisecond)
	}
}