@pi-in-go/pigpen-jev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +22 -0
- package/LICENSE +22 -0
- package/README.md +237 -0
- package/extensions/jev/ask.go +166 -0
- package/extensions/jev/ask_test.go +218 -0
- package/extensions/jev/backend.go +128 -0
- package/extensions/jev/bench_test.go +64 -0
- package/extensions/jev/boundaries_test.go +159 -0
- package/extensions/jev/command.go +224 -0
- package/extensions/jev/commands_test.go +214 -0
- package/extensions/jev/config.go +450 -0
- package/extensions/jev/errors_test.go +191 -0
- package/extensions/jev/extension.go +391 -0
- package/extensions/jev/fakehost_test.go +548 -0
- package/extensions/jev/gate.go +125 -0
- package/extensions/jev/gate_test.go +610 -0
- package/extensions/jev/gatekey_test.go +24 -0
- package/extensions/jev/go.mod +9 -0
- package/extensions/jev/go.sum +2 -0
- package/extensions/jev/go.work +10 -0
- package/extensions/jev/helpers_test.go +404 -0
- package/extensions/jev/memo.go +88 -0
- package/extensions/jev/output.go +89 -0
- package/extensions/jev/output_test.go +187 -0
- package/extensions/jev/ownmodel_test.go +118 -0
- package/extensions/jev/render.go +136 -0
- package/extensions/jev/review_test.go +310 -0
- package/extensions/jev/source_test.go +57 -0
- package/extensions/jev/text.go +174 -0
- package/extensions/jev/trust_test.go +335 -0
- package/extensions/jev/types.go +227 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +107 -0
- package/port/e2e/gate-and-output.py +35 -0
- package/port/e2e/jev-ask.py +36 -0
- package/port/e2e/model-switch.py +44 -0
- package/port/e2e/off-by-default.py +34 -0
- package/port/gen-scenarios.py +103 -0
- package/port/golden/cache-identical-calls.jsonl +30 -0
- package/port/golden/clear.jsonl +22 -0
- package/port/golden/commands.jsonl +43 -0
- package/port/golden/enforce-accept.jsonl +23 -0
- package/port/golden/enforce-decline.jsonl +22 -0
- package/port/golden/jev-ask.jsonl +20 -0
- package/port/golden/output-advice.jsonl +23 -0
- package/port/golden/output-leak.jsonl +24 -0
- package/port/golden/output-low-confidence.jsonl +22 -0
- package/port/golden/shadow-flagged.jsonl +23 -0
- package/port/golden/unjudged-tools.jsonl +19 -0
- package/port/golden/write-elision.jsonl +21 -0
- package/port/mutate-unit.py +63 -0
- package/port/mutations.json +578 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +181 -0
- package/port/oracle/SHA256SUMS +8 -0
- package/port/oracle/package.json +43 -0
- package/port/oracle/src/client.ts +409 -0
- package/port/oracle/src/config.ts +363 -0
- package/port/oracle/src/gate.ts +229 -0
- package/port/oracle/src/index.ts +649 -0
- package/port/oracle/src/output.ts +163 -0
- package/port/red-run.log +309 -0
- package/port/scenarios/cache-identical-calls.json +71 -0
- package/port/scenarios/clear.json +61 -0
- package/port/scenarios/commands.json +119 -0
- package/port/scenarios/enforce-accept.json +66 -0
- package/port/scenarios/enforce-decline.json +57 -0
- package/port/scenarios/jev-ask.json +83 -0
- package/port/scenarios/output-advice.json +61 -0
- package/port/scenarios/output-leak.json +61 -0
- package/port/scenarios/output-low-confidence.json +61 -0
- package/port/scenarios/shadow-flagged.json +61 -0
- package/port/scenarios/unjudged-tools.json +55 -0
- package/port/scenarios/write-elision.json +53 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
package typesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"math"
|
|
7
|
+
"math/rand/v2"
|
|
8
|
+
"net/http"
|
|
9
|
+
"regexp"
|
|
10
|
+
"runtime"
|
|
11
|
+
"slices"
|
|
12
|
+
"strconv"
|
|
13
|
+
"strings"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// RetryPolicy is the resolved retry configuration.
|
|
18
|
+
type RetryPolicy struct {
|
|
19
|
+
// MaxRetries is the number of retries after the first attempt; 0 disables retries. Default 2.
|
|
20
|
+
MaxRetries int
|
|
21
|
+
// BackoffInitial is the first backoff delay, doubled per retry up to BackoffMax. Default 500ms.
|
|
22
|
+
BackoffInitial time.Duration
|
|
23
|
+
// BackoffMax is the largest backoff delay. Default 5s.
|
|
24
|
+
BackoffMax time.Duration
|
|
25
|
+
// BackoffJitter is the fraction of each delay randomly subtracted, from 0 to 1. Default 0.25.
|
|
26
|
+
BackoffJitter float64
|
|
27
|
+
// HTTPStatuses are the retried status codes, ascending. Default 408, 429 and 500 to 599.
|
|
28
|
+
HTTPStatuses []int
|
|
29
|
+
// RespectRetryAfter honors Retry-After and retry-after-ms up to MaxRetryAfter. Default true.
|
|
30
|
+
RespectRetryAfter bool
|
|
31
|
+
// MaxRetryAfter is the longest server delay honored; longer ones use backoff. Default 60s.
|
|
32
|
+
MaxRetryAfter time.Duration
|
|
33
|
+
// APIConnectionError retries connection failures, including interrupted bodies. Default true.
|
|
34
|
+
APIConnectionError bool
|
|
35
|
+
// APITimeoutError retries per-attempt timeouts. Default true.
|
|
36
|
+
APITimeoutError bool
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// DefaultTimeout is the per-attempt timeout when none is configured (10s).
|
|
40
|
+
const DefaultTimeout = 10 * time.Second
|
|
41
|
+
|
|
42
|
+
// DefaultRetryPolicy returns a fresh copy of the SDK defaults.
|
|
43
|
+
func DefaultRetryPolicy() RetryPolicy {
|
|
44
|
+
statuses := []int{408, 429}
|
|
45
|
+
for s := 500; s < 600; s++ {
|
|
46
|
+
statuses = append(statuses, s)
|
|
47
|
+
}
|
|
48
|
+
return RetryPolicy{
|
|
49
|
+
MaxRetries: 2,
|
|
50
|
+
BackoffInitial: 500 * time.Millisecond,
|
|
51
|
+
BackoffMax: 5 * time.Second,
|
|
52
|
+
BackoffJitter: 0.25,
|
|
53
|
+
HTTPStatuses: statuses,
|
|
54
|
+
RespectRetryAfter: true,
|
|
55
|
+
MaxRetryAfter: 60 * time.Second,
|
|
56
|
+
APIConnectionError: true,
|
|
57
|
+
APITimeoutError: true,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// RetryOverrides changes some fields of a policy: nil pointers and a nil HTTPStatuses
|
|
62
|
+
// inherit; a non-nil empty HTTPStatuses retries no status.
|
|
63
|
+
type RetryOverrides struct {
|
|
64
|
+
MaxRetries *int
|
|
65
|
+
BackoffInitial *time.Duration
|
|
66
|
+
BackoffMax *time.Duration
|
|
67
|
+
BackoffJitter *float64
|
|
68
|
+
HTTPStatuses []int
|
|
69
|
+
RespectRetryAfter *bool
|
|
70
|
+
MaxRetryAfter *time.Duration
|
|
71
|
+
APIConnectionError *bool
|
|
72
|
+
APITimeoutError *bool
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Ptr returns a pointer to v, for the pointer fields of [RetryOverrides].
|
|
76
|
+
func Ptr[T any](v T) *T { return &v }
|
|
77
|
+
|
|
78
|
+
func nonNegativeDuration(name string, d time.Duration) (time.Duration, error) {
|
|
79
|
+
if d < 0 {
|
|
80
|
+
return 0, errorf("`%s` must be a non-negative duration, got %s.", name, d)
|
|
81
|
+
}
|
|
82
|
+
return d, nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve applies o over p and validates every field; the error names the field
|
|
86
|
+
// ("Retry.MaxRetries", "Retry.BackoffJitter", "Retry.HTTPStatuses", ...). The result
|
|
87
|
+
// shares no memory with p or o.
|
|
88
|
+
func (p RetryPolicy) Resolve(o RetryOverrides) (RetryPolicy, error) {
|
|
89
|
+
out := p
|
|
90
|
+
out.HTTPStatuses = slices.Clone(p.HTTPStatuses)
|
|
91
|
+
var err error
|
|
92
|
+
if o.MaxRetries != nil {
|
|
93
|
+
if *o.MaxRetries < 0 {
|
|
94
|
+
return RetryPolicy{}, errorf("`Retry.MaxRetries` must be a non-negative integer, got %d.", *o.MaxRetries)
|
|
95
|
+
}
|
|
96
|
+
out.MaxRetries = *o.MaxRetries
|
|
97
|
+
}
|
|
98
|
+
if o.BackoffInitial != nil {
|
|
99
|
+
if out.BackoffInitial, err = nonNegativeDuration("Retry.BackoffInitial", *o.BackoffInitial); err != nil {
|
|
100
|
+
return RetryPolicy{}, err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if o.BackoffMax != nil {
|
|
104
|
+
if out.BackoffMax, err = nonNegativeDuration("Retry.BackoffMax", *o.BackoffMax); err != nil {
|
|
105
|
+
return RetryPolicy{}, err
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if o.BackoffJitter != nil {
|
|
109
|
+
j := *o.BackoffJitter
|
|
110
|
+
if math.IsNaN(j) || math.IsInf(j, 0) || j < 0 || j > 1 {
|
|
111
|
+
return RetryPolicy{}, errorf("`Retry.BackoffJitter` must be between 0 and 1, got %v.", j)
|
|
112
|
+
}
|
|
113
|
+
out.BackoffJitter = j
|
|
114
|
+
}
|
|
115
|
+
if o.HTTPStatuses != nil {
|
|
116
|
+
for _, s := range o.HTTPStatuses {
|
|
117
|
+
if s < 100 || s > 999 {
|
|
118
|
+
return RetryPolicy{}, errorf("`Retry.HTTPStatuses` must contain HTTP status codes, got %d.", s)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
out.HTTPStatuses = slices.Clone(o.HTTPStatuses)
|
|
122
|
+
slices.Sort(out.HTTPStatuses)
|
|
123
|
+
out.HTTPStatuses = slices.Compact(out.HTTPStatuses)
|
|
124
|
+
}
|
|
125
|
+
if o.RespectRetryAfter != nil {
|
|
126
|
+
out.RespectRetryAfter = *o.RespectRetryAfter
|
|
127
|
+
}
|
|
128
|
+
if o.MaxRetryAfter != nil {
|
|
129
|
+
if out.MaxRetryAfter, err = nonNegativeDuration("Retry.MaxRetryAfter", *o.MaxRetryAfter); err != nil {
|
|
130
|
+
return RetryPolicy{}, err
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if o.APIConnectionError != nil {
|
|
134
|
+
out.APIConnectionError = *o.APIConnectionError
|
|
135
|
+
}
|
|
136
|
+
if o.APITimeoutError != nil {
|
|
137
|
+
out.APITimeoutError = *o.APITimeoutError
|
|
138
|
+
}
|
|
139
|
+
return out, nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// RetriesStatus reports whether the policy retries an HTTP status code.
|
|
143
|
+
func (p RetryPolicy) RetriesStatus(status int) bool {
|
|
144
|
+
if slices.IsSorted(p.HTTPStatuses) {
|
|
145
|
+
_, found := slices.BinarySearch(p.HTTPStatuses, status)
|
|
146
|
+
return found
|
|
147
|
+
}
|
|
148
|
+
return slices.Contains(p.HTTPStatuses, status)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
var (
|
|
152
|
+
jsDecimal = regexp.MustCompile(`^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$`)
|
|
153
|
+
jsSpace = strings.NewReplacer("\ufeff", "")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// jsNumber is JavaScript's Number(string): whitespace-trimmed, "" is 0, decimal and
|
|
157
|
+
// 0x/0o/0b literals, "Infinity"; anything else is NaN.
|
|
158
|
+
func jsNumber(s string) float64 {
|
|
159
|
+
s = strings.TrimSpace(jsSpace.Replace(s))
|
|
160
|
+
if s == "" {
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
if len(s) > 2 && s[0] == '0' {
|
|
164
|
+
base := map[byte]int{'x': 16, 'X': 16, 'o': 8, 'O': 8, 'b': 2, 'B': 2}[s[1]]
|
|
165
|
+
if base != 0 {
|
|
166
|
+
n, err := strconv.ParseUint(s[2:], base, 64)
|
|
167
|
+
if err != nil {
|
|
168
|
+
return math.NaN()
|
|
169
|
+
}
|
|
170
|
+
return float64(n)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
switch s {
|
|
174
|
+
case "Infinity", "+Infinity":
|
|
175
|
+
return math.Inf(1)
|
|
176
|
+
case "-Infinity":
|
|
177
|
+
return math.Inf(-1)
|
|
178
|
+
}
|
|
179
|
+
if !jsDecimal.MatchString(s) {
|
|
180
|
+
return math.NaN()
|
|
181
|
+
}
|
|
182
|
+
f, err := strconv.ParseFloat(s, 64)
|
|
183
|
+
if err != nil && !errors.Is(err, strconv.ErrRange) {
|
|
184
|
+
return math.NaN()
|
|
185
|
+
}
|
|
186
|
+
return f
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func finite(f float64) bool { return !math.IsNaN(f) && !math.IsInf(f, 0) }
|
|
190
|
+
|
|
191
|
+
var httpDateLayouts = []string{http.TimeFormat, time.RFC1123, time.RFC1123Z, time.RFC850, time.ANSIC, time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05"}
|
|
192
|
+
|
|
193
|
+
func parseHTTPDate(s string) (time.Time, bool) {
|
|
194
|
+
s = strings.TrimSpace(s)
|
|
195
|
+
for _, layout := range httpDateLayouts {
|
|
196
|
+
if t, err := time.Parse(layout, s); err == nil {
|
|
197
|
+
return t, true
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return time.Time{}, false
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// msToDuration converts milliseconds, saturating at the largest Duration: converting an
|
|
204
|
+
// out-of-range float to an integer wraps (to a negative value on amd64), which would turn a
|
|
205
|
+
// huge Retry-After into an immediate retry where JavaScript falls back to backoff.
|
|
206
|
+
func msToDuration(ms float64) time.Duration {
|
|
207
|
+
ns := ms * float64(time.Millisecond)
|
|
208
|
+
if ns >= math.MaxInt64 {
|
|
209
|
+
return time.Duration(math.MaxInt64)
|
|
210
|
+
}
|
|
211
|
+
return time.Duration(ns)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ParseRetryAfter reads retry-after-ms, else Retry-After (seconds, or an HTTP date
|
|
215
|
+
// relative to now). ok is false when neither holds a valid delay.
|
|
216
|
+
func ParseRetryAfter(h http.Header, now time.Time) (d time.Duration, ok bool) {
|
|
217
|
+
if vals := h.Values("retry-after-ms"); len(vals) > 0 {
|
|
218
|
+
if ms := jsNumber(vals[0]); finite(ms) && ms >= 0 {
|
|
219
|
+
return msToDuration(ms), true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
vals := h.Values("retry-after")
|
|
223
|
+
if len(vals) == 0 {
|
|
224
|
+
return 0, false
|
|
225
|
+
}
|
|
226
|
+
raw := vals[0]
|
|
227
|
+
if seconds := jsNumber(raw); finite(seconds) {
|
|
228
|
+
if seconds >= 0 {
|
|
229
|
+
return msToDuration(seconds * 1000), true
|
|
230
|
+
}
|
|
231
|
+
return 0, false
|
|
232
|
+
}
|
|
233
|
+
if date, ok := parseHTTPDate(raw); ok {
|
|
234
|
+
if d := date.Sub(now); d > 0 {
|
|
235
|
+
return d, true
|
|
236
|
+
}
|
|
237
|
+
return 0, true
|
|
238
|
+
}
|
|
239
|
+
return 0, false
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Delay returns the wait before retry number attempt+1 (attempt is zero-based): an
|
|
243
|
+
// allowed server delay, else capped exponential backoff with jitter. random returns
|
|
244
|
+
// [0,1); nil uses math/rand.
|
|
245
|
+
func (p RetryPolicy) Delay(attempt int, h http.Header, random func() float64) time.Duration {
|
|
246
|
+
if p.RespectRetryAfter && h != nil {
|
|
247
|
+
if ra, ok := ParseRetryAfter(h, time.Now()); ok && ra <= p.MaxRetryAfter {
|
|
248
|
+
return ra
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if random == nil {
|
|
252
|
+
random = rand.Float64
|
|
253
|
+
}
|
|
254
|
+
initial := float64(p.BackoffInitial) / float64(time.Millisecond)
|
|
255
|
+
maxMs := float64(p.BackoffMax) / float64(time.Millisecond)
|
|
256
|
+
exponential := math.Min(initial*math.Pow(2, float64(attempt)), maxMs)
|
|
257
|
+
ms := math.Floor(exponential*(1-random()*p.BackoffJitter) + 0.5) // Math.round
|
|
258
|
+
if math.IsNaN(ms) || ms < 0 {
|
|
259
|
+
ms = 0
|
|
260
|
+
}
|
|
261
|
+
return msToDuration(ms)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// RetryHooks observe a retry loop.
|
|
265
|
+
type RetryHooks struct {
|
|
266
|
+
// Random supplies jitter in [0,1); nil uses math/rand.
|
|
267
|
+
Random func() float64
|
|
268
|
+
// OnRetry is called before each wait: retry is 1-based, of total retries. reason is
|
|
269
|
+
// the status code of an *APIError, else the error's message.
|
|
270
|
+
OnRetry func(retry, total int, delay time.Duration, reason string)
|
|
271
|
+
// OnAbortDuringWait is called when the context ends while waiting to retry.
|
|
272
|
+
OnAbortDuringWait func()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func (p RetryPolicy) retries(err error) bool {
|
|
276
|
+
var abort *APIUserAbortError
|
|
277
|
+
if errors.As(err, &abort) {
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
var timeout *APITimeoutError
|
|
281
|
+
if errors.As(err, &timeout) {
|
|
282
|
+
return p.APITimeoutError
|
|
283
|
+
}
|
|
284
|
+
var conn *APIConnectionError
|
|
285
|
+
if errors.As(err, &conn) {
|
|
286
|
+
return p.APIConnectionError
|
|
287
|
+
}
|
|
288
|
+
var api *APIError
|
|
289
|
+
if errors.As(err, &api) {
|
|
290
|
+
return p.RetriesStatus(api.Status)
|
|
291
|
+
}
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Retry runs fn with the policy: fn gets the zero-based attempt number. Errors are
|
|
296
|
+
// retried when they are a *TypeSafeError-family API error whose status the policy
|
|
297
|
+
// retries, an *APITimeoutError (APITimeoutError policy flag) or another
|
|
298
|
+
// *APIConnectionError (APIConnectionError flag); an *APIUserAbortError, any other
|
|
299
|
+
// error, and a cancelled ctx are never retried. The wait honors Retry-After from an
|
|
300
|
+
// *APIError's headers per the policy, and a cancelled ctx during the wait returns an
|
|
301
|
+
// *APIUserAbortError. The last error is returned when retries run out.
|
|
302
|
+
func Retry[T any](ctx context.Context, p RetryPolicy, hooks RetryHooks, fn func(ctx context.Context, attempt int) (T, error)) (T, error) {
|
|
303
|
+
var zero T
|
|
304
|
+
for attempt := 0; ; attempt++ {
|
|
305
|
+
v, err := fn(ctx, attempt)
|
|
306
|
+
if err == nil {
|
|
307
|
+
return v, nil
|
|
308
|
+
}
|
|
309
|
+
if p.MaxRetries-attempt <= 0 || !p.retries(err) {
|
|
310
|
+
return zero, err
|
|
311
|
+
}
|
|
312
|
+
var header http.Header
|
|
313
|
+
reason := err.Error()
|
|
314
|
+
var api *APIError
|
|
315
|
+
if errors.As(err, &api) {
|
|
316
|
+
header = api.Header
|
|
317
|
+
reason = strconv.Itoa(api.Status)
|
|
318
|
+
}
|
|
319
|
+
delay := p.Delay(attempt, header, hooks.Random)
|
|
320
|
+
if hooks.OnRetry != nil {
|
|
321
|
+
hooks.OnRetry(attempt+1, p.MaxRetries, delay, reason)
|
|
322
|
+
}
|
|
323
|
+
if werr := sleepCtx(ctx, delay); werr != nil {
|
|
324
|
+
if hooks.OnAbortDuringWait != nil {
|
|
325
|
+
hooks.OnAbortDuringWait()
|
|
326
|
+
}
|
|
327
|
+
return zero, newAbortError(werr)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// sleepCtx waits d, returning context.Cause(ctx) as soon as ctx ends.
|
|
333
|
+
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
334
|
+
if ctx.Err() != nil {
|
|
335
|
+
return context.Cause(ctx)
|
|
336
|
+
}
|
|
337
|
+
timer := time.NewTimer(d)
|
|
338
|
+
defer timer.Stop()
|
|
339
|
+
select {
|
|
340
|
+
case <-timer.C:
|
|
341
|
+
return nil
|
|
342
|
+
case <-ctx.Done():
|
|
343
|
+
return context.Cause(ctx)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// describeRuntime returns the value of the X-TypeSafe-Runtime header: "go/<version> (<os>; <arch>)".
|
|
348
|
+
func describeRuntime() string {
|
|
349
|
+
return "go/" + strings.TrimPrefix(runtime.Version(), "go") + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")"
|
|
350
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
package typesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"math"
|
|
7
|
+
"net/http"
|
|
8
|
+
"reflect"
|
|
9
|
+
"sort"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func h(pairs ...string) http.Header {
|
|
15
|
+
out := http.Header{}
|
|
16
|
+
for i := 0; i+1 < len(pairs); i += 2 {
|
|
17
|
+
out.Set(pairs[i], pairs[i+1])
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func policyWith(mutate func(*RetryPolicy)) RetryPolicy {
|
|
23
|
+
p := DefaultRetryPolicy()
|
|
24
|
+
mutate(&p)
|
|
25
|
+
return p
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func ms(n int64) time.Duration { return time.Duration(n) * time.Millisecond }
|
|
29
|
+
|
|
30
|
+
func TestDefaultRetryPolicy_UsesTheSDKRetryDefaults(t *testing.T) {
|
|
31
|
+
twin(t, "retry.test.ts | DEFAULT_RETRY_POLICY uses the SDK retry defaults")
|
|
32
|
+
p := DefaultRetryPolicy()
|
|
33
|
+
want := []int{408, 429}
|
|
34
|
+
for s := 500; s < 600; s++ {
|
|
35
|
+
want = append(want, s)
|
|
36
|
+
}
|
|
37
|
+
statuses := append([]int(nil), p.HTTPStatuses...)
|
|
38
|
+
sort.Ints(statuses)
|
|
39
|
+
eq(t, statuses, want)
|
|
40
|
+
p.HTTPStatuses = nil
|
|
41
|
+
eq(t, p, RetryPolicy{MaxRetries: 2, BackoffInitial: ms(500), BackoffMax: ms(5000), BackoffJitter: 0.25, RespectRetryAfter: true, MaxRetryAfter: ms(60000), APIConnectionError: true, APITimeoutError: true})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestIsRetryableStatus_ByDefault(t *testing.T) {
|
|
45
|
+
twin(t,
|
|
46
|
+
"retry.test.ts | isRetryableStatus retries 408 by default", "retry.test.ts | isRetryableStatus retries 429 by default",
|
|
47
|
+
"retry.test.ts | isRetryableStatus retries 500 by default", "retry.test.ts | isRetryableStatus retries 502 by default",
|
|
48
|
+
"retry.test.ts | isRetryableStatus retries 503 by default", "retry.test.ts | isRetryableStatus retries 504 by default",
|
|
49
|
+
"retry.test.ts | isRetryableStatus retries 529 by default", "retry.test.ts | isRetryableStatus retries 599 by default",
|
|
50
|
+
"retry.test.ts | isRetryableStatus does not retry 200 by default", "retry.test.ts | isRetryableStatus does not retry 400 by default",
|
|
51
|
+
"retry.test.ts | isRetryableStatus does not retry 401 by default", "retry.test.ts | isRetryableStatus does not retry 403 by default",
|
|
52
|
+
"retry.test.ts | isRetryableStatus does not retry 404 by default", "retry.test.ts | isRetryableStatus does not retry 409 by default",
|
|
53
|
+
"retry.test.ts | isRetryableStatus does not retry 422 by default", "retry.test.ts | isRetryableStatus does not retry 600 by default")
|
|
54
|
+
p := DefaultRetryPolicy()
|
|
55
|
+
for _, s := range []int{408, 429, 500, 502, 503, 504, 529, 599} {
|
|
56
|
+
if !p.RetriesStatus(s) {
|
|
57
|
+
t.Fatalf("%d must be retried", s)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for _, s := range []int{200, 400, 401, 403, 404, 409, 422, 600} {
|
|
61
|
+
if p.RetriesStatus(s) {
|
|
62
|
+
t.Fatalf("%d must not be retried", s)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestIsRetryableStatus_ConsultsThePolicysStatusSet(t *testing.T) {
|
|
68
|
+
twin(t, "retry.test.ts | isRetryableStatus consults the policy's status set")
|
|
69
|
+
p := policyWith(func(p *RetryPolicy) { p.HTTPStatuses = []int{409} })
|
|
70
|
+
if !p.RetriesStatus(409) || p.RetriesStatus(503) {
|
|
71
|
+
t.Fatal("only 409 is retried")
|
|
72
|
+
}
|
|
73
|
+
if policyWith(func(p *RetryPolicy) { p.HTTPStatuses = []int{} }).RetriesStatus(503) {
|
|
74
|
+
t.Fatal("an empty set retries nothing")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func TestParseRetryAfter_ReadsRetryAfterInSeconds(t *testing.T) {
|
|
79
|
+
twin(t, "retry.test.ts | parseRetryAfter reads Retry-After in seconds")
|
|
80
|
+
now := time.Now()
|
|
81
|
+
for _, tc := range []struct {
|
|
82
|
+
v string
|
|
83
|
+
want time.Duration
|
|
84
|
+
}{{"3", 3000 * time.Millisecond}, {"0", 0}, {"1.5", 1500 * time.Millisecond}} {
|
|
85
|
+
d, ok := ParseRetryAfter(h("retry-after", tc.v), now)
|
|
86
|
+
if !ok || d != tc.want {
|
|
87
|
+
t.Fatalf("%q: got %v %v, want %v", tc.v, d, ok, tc.want)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestParseRetryAfter_PrefersRetryAfterMsWhenPresent(t *testing.T) {
|
|
93
|
+
twin(t, "retry.test.ts | parseRetryAfter prefers retry-after-ms when present")
|
|
94
|
+
d, ok := ParseRetryAfter(h("retry-after-ms", "250", "retry-after", "3"), time.Now())
|
|
95
|
+
if !ok || d != ms(250) {
|
|
96
|
+
t.Fatalf("got %v %v", d, ok)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestParseRetryAfter_ReadsAnHTTPDateRelativeToNow(t *testing.T) {
|
|
101
|
+
twin(t, "retry.test.ts | parseRetryAfter reads an HTTP date relative to now")
|
|
102
|
+
now := time.Date(2026, 10, 21, 7, 28, 0, 0, time.UTC)
|
|
103
|
+
d, ok := ParseRetryAfter(h("retry-after", "Wed, 21 Oct 2026 07:28:05 GMT"), now)
|
|
104
|
+
if !ok || d != 5*time.Second {
|
|
105
|
+
t.Fatalf("got %v %v", d, ok)
|
|
106
|
+
}
|
|
107
|
+
d, ok = ParseRetryAfter(h("retry-after", "Wed, 21 Oct 2026 07:27:00 GMT"), now)
|
|
108
|
+
if !ok || d != 0 {
|
|
109
|
+
t.Fatalf("a past date is zero: %v %v", d, ok)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func TestParseRetryAfter_ReturnsUndefinedForMissingOrGarbageValues(t *testing.T) {
|
|
114
|
+
twin(t, "retry.test.ts | parseRetryAfter returns undefined for missing or garbage values")
|
|
115
|
+
now := time.Now()
|
|
116
|
+
for _, hdr := range []http.Header{h(), h("retry-after", "soon"), h("retry-after", "-5"), h("retry-after-ms", "nope")} {
|
|
117
|
+
if d, ok := ParseRetryAfter(hdr, now); ok {
|
|
118
|
+
t.Fatalf("%v: want none, got %v", hdr, d)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestParseRetryAfter_FollowsJavaScriptNumberSemantics(t *testing.T) {
|
|
124
|
+
// Number(" 5 "), Number("1e3"), Number("0x10") and Number("") are finite in JavaScript.
|
|
125
|
+
now := time.Now()
|
|
126
|
+
for _, tc := range []struct {
|
|
127
|
+
v string
|
|
128
|
+
want time.Duration
|
|
129
|
+
ok bool
|
|
130
|
+
}{{" 5 ", 5 * time.Second, true}, {"1e3", 1000 * time.Second, true}, {"0x10", 16 * time.Second, true}, {"", 0, true}, {"Infinity", 0, false}, {"inf", 0, false}, {"nan", 0, false}, {"1_0", 0, false}} {
|
|
131
|
+
d, ok := ParseRetryAfter(h("retry-after", tc.v), now)
|
|
132
|
+
if ok != tc.ok || (ok && d != tc.want) {
|
|
133
|
+
t.Fatalf("%q: got %v %v, want %v %v", tc.v, d, ok, tc.want, tc.ok)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestRetryDelayMs_GrowsExponentiallyFrom500msAndCapsAt5s(t *testing.T) {
|
|
139
|
+
twin(t, "retry.test.ts | retryDelayMs grows exponentially from 500ms and caps at 5s")
|
|
140
|
+
var got []time.Duration
|
|
141
|
+
for a := 0; a < 6; a++ {
|
|
142
|
+
got = append(got, DefaultRetryPolicy().Delay(a, nil, func() float64 { return 0 }))
|
|
143
|
+
}
|
|
144
|
+
eq(t, got, []time.Duration{ms(500), ms(1000), ms(2000), ms(4000), ms(5000), ms(5000)})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestRetryDelayMs_ShavesOffAtMost25PercentAsJitter(t *testing.T) {
|
|
148
|
+
twin(t, "retry.test.ts | retryDelayMs shaves off at most 25% as jitter")
|
|
149
|
+
p := DefaultRetryPolicy()
|
|
150
|
+
eq(t, p.Delay(0, nil, func() float64 { return 1 }), ms(375))
|
|
151
|
+
eq(t, p.Delay(1, nil, func() float64 { return 0.5 }), ms(875))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestRetryDelayMs_HonorsRetryAfterExactlyWithNoJitter(t *testing.T) {
|
|
155
|
+
twin(t, "retry.test.ts | retryDelayMs honors Retry-After exactly, with no jitter")
|
|
156
|
+
p := DefaultRetryPolicy()
|
|
157
|
+
one := func() float64 { return 1 }
|
|
158
|
+
eq(t, p.Delay(0, h("retry-after", "2"), one), ms(2000))
|
|
159
|
+
eq(t, p.Delay(3, h("retry-after-ms", "10"), one), ms(10))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func TestRetryDelayMs_FallsBackToBackoffWhenRetryAfterExceedsOneMinute(t *testing.T) {
|
|
163
|
+
twin(t, "retry.test.ts | retryDelayMs falls back to backoff when Retry-After exceeds one minute")
|
|
164
|
+
p := DefaultRetryPolicy()
|
|
165
|
+
zero := func() float64 { return 0 }
|
|
166
|
+
eq(t, p.Delay(0, h("retry-after", "61"), zero), ms(500))
|
|
167
|
+
eq(t, p.Delay(0, h("retry-after", "60"), zero), ms(60000))
|
|
168
|
+
eq(t, p.Delay(0, h("retry-after-ms", "60000"), zero), ms(60000))
|
|
169
|
+
eq(t, p.Delay(0, h("retry-after-ms", "60001"), func() float64 { return 1 }), ms(375))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestRetryDelayMs_UsesThePolicysInitialDelayCapAndJitter(t *testing.T) {
|
|
173
|
+
twin(t, "retry.test.ts | retryDelayMs uses the policy's initial delay, cap, and jitter")
|
|
174
|
+
p := policyWith(func(p *RetryPolicy) { p.BackoffInitial, p.BackoffMax, p.BackoffJitter = ms(100), ms(350), 0.5 })
|
|
175
|
+
var got []time.Duration
|
|
176
|
+
for a := 0; a < 4; a++ {
|
|
177
|
+
got = append(got, p.Delay(a, nil, func() float64 { return 0 }))
|
|
178
|
+
}
|
|
179
|
+
eq(t, got, []time.Duration{ms(100), ms(200), ms(350), ms(350)})
|
|
180
|
+
eq(t, p.Delay(0, nil, func() float64 { return 1 }), ms(50))
|
|
181
|
+
eq(t, policyWith(func(p *RetryPolicy) { p.BackoffJitter = 0 }).Delay(0, nil, func() float64 { return 1 }), ms(500))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func TestRetryDelayMs_CanIgnoreRetryAfterEntirely(t *testing.T) {
|
|
185
|
+
twin(t, "retry.test.ts | retryDelayMs can ignore Retry-After entirely")
|
|
186
|
+
p := policyWith(func(p *RetryPolicy) { p.RespectRetryAfter = false })
|
|
187
|
+
eq(t, p.Delay(0, h("retry-after", "2"), func() float64 { return 0 }), ms(500))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func TestRetryDelayMs_UsesThePolicysRetryAfterCeiling(t *testing.T) {
|
|
191
|
+
twin(t, "retry.test.ts | retryDelayMs uses the policy's Retry-After ceiling")
|
|
192
|
+
p := policyWith(func(p *RetryPolicy) { p.MaxRetryAfter = ms(1000) })
|
|
193
|
+
zero := func() float64 { return 0 }
|
|
194
|
+
eq(t, p.Delay(0, h("retry-after", "1"), zero), ms(1000))
|
|
195
|
+
eq(t, p.Delay(0, h("retry-after", "2"), zero), ms(500))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Go-specific (review): a server delay beyond time.Duration's range must not wrap to a
|
|
199
|
+
// negative duration. JavaScript compares the huge number with the ceiling and falls back
|
|
200
|
+
// to backoff; a wrapped Go value passed the ceiling check and retried at once.
|
|
201
|
+
func TestRetryDelayMs_HugeRetryAfterFallsBackToBackoffNotANegativeDelay(t *testing.T) {
|
|
202
|
+
p := DefaultRetryPolicy()
|
|
203
|
+
zero := func() float64 { return 0 }
|
|
204
|
+
for _, hdr := range []http.Header{
|
|
205
|
+
h("retry-after", "1e12"), h("retry-after", "9999999999999"), h("retry-after", "1e300"),
|
|
206
|
+
h("retry-after-ms", "1e13"), h("retry-after-ms", "1e300"),
|
|
207
|
+
} {
|
|
208
|
+
if d, ok := ParseRetryAfter(hdr, time.Now()); !ok || d <= 0 {
|
|
209
|
+
t.Errorf("ParseRetryAfter(%v) = %v, %v; want a large positive delay", hdr, d, ok)
|
|
210
|
+
}
|
|
211
|
+
eq(t, p.Delay(0, hdr, zero), ms(500))
|
|
212
|
+
}
|
|
213
|
+
err := NewAPIError(429, nil, h("retry-after", "1e12"))
|
|
214
|
+
if rl := mustAs[*RateLimitError](t, err); !rl.HasRetryAfter || rl.RetryAfter <= 0 {
|
|
215
|
+
t.Errorf("RateLimitError.RetryAfter = %v, %v; want a large positive delay", rl.RetryAfter, rl.HasRetryAfter)
|
|
216
|
+
}
|
|
217
|
+
// A backoff cap at the top of the range must not wrap either.
|
|
218
|
+
huge := policyWith(func(p *RetryPolicy) {
|
|
219
|
+
p.BackoffInitial, p.BackoffMax, p.BackoffJitter = time.Duration(math.MaxInt64), time.Duration(math.MaxInt64), 0
|
|
220
|
+
})
|
|
221
|
+
if d := huge.Delay(0, nil, zero); d <= 0 {
|
|
222
|
+
t.Errorf("Delay with a maximal backoff = %v; want positive", d)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func TestSleep_RejectsWithTheSignalsReasonWhenAbortedMidSleep(t *testing.T) {
|
|
227
|
+
twin(t, "retry.test.ts | sleep rejects with the signal's reason when aborted mid-sleep")
|
|
228
|
+
ctx, cancel := context.WithCancelCause(context.Background())
|
|
229
|
+
done := make(chan error, 1)
|
|
230
|
+
go func() { done <- sleepCtx(ctx, 10*time.Second) }()
|
|
231
|
+
cancel(errors.New("stop"))
|
|
232
|
+
err := <-done
|
|
233
|
+
if err == nil || err.Error() != "stop" {
|
|
234
|
+
t.Fatalf("got %v", err)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func TestSleep_RejectsImmediatelyIfTheSignalIsAlreadyAborted(t *testing.T) {
|
|
239
|
+
twin(t, "retry.test.ts | sleep rejects immediately if the signal is already aborted")
|
|
240
|
+
ctx, cancel := context.WithCancelCause(context.Background())
|
|
241
|
+
cancel(errors.New("already"))
|
|
242
|
+
err := sleepCtx(ctx, 10*time.Second)
|
|
243
|
+
if err == nil || err.Error() != "already" {
|
|
244
|
+
t.Fatalf("got %v", err)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func TestSleep_WaitsTheDuration(t *testing.T) {
|
|
249
|
+
start := time.Now()
|
|
250
|
+
noErr(t, sleepCtx(context.Background(), 20*time.Millisecond))
|
|
251
|
+
if time.Since(start) < 20*time.Millisecond {
|
|
252
|
+
t.Fatal("returned early")
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Go-specific: the exported Retry loop.
|
|
257
|
+
|
|
258
|
+
func TestRetry_LoopRetriesRetryableErrorsAndReportsEachRetry(t *testing.T) {
|
|
259
|
+
var reasons []string
|
|
260
|
+
calls := 0
|
|
261
|
+
out, err := Retry(context.Background(), policyWith(func(p *RetryPolicy) { p.BackoffInitial, p.BackoffJitter = 0, 0 }), RetryHooks{
|
|
262
|
+
OnRetry: func(retry, total int, d time.Duration, reason string) { reasons = append(reasons, reason) },
|
|
263
|
+
}, func(ctx context.Context, attempt int) (string, error) {
|
|
264
|
+
calls++
|
|
265
|
+
if attempt < 2 {
|
|
266
|
+
return "", NewAPIError(503, nil, http.Header{})
|
|
267
|
+
}
|
|
268
|
+
return "done", nil
|
|
269
|
+
})
|
|
270
|
+
noErr(t, err)
|
|
271
|
+
eq(t, out, "done")
|
|
272
|
+
eq(t, calls, 3)
|
|
273
|
+
eq(t, len(reasons), 2)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func TestRetry_LoopDoesNotRetryOtherErrorsOrAborts(t *testing.T) {
|
|
277
|
+
for _, e := range []error{errors.New("plain"), NewAPIError(400, nil, http.Header{}), &APIUserAbortError{TypeSafeError: &TypeSafeError{Message: "Request was aborted."}}} {
|
|
278
|
+
calls := 0
|
|
279
|
+
_, err := Retry(context.Background(), DefaultRetryPolicy(), RetryHooks{}, func(ctx context.Context, attempt int) (int, error) { calls++; return 0, e })
|
|
280
|
+
if err == nil || calls != 1 {
|
|
281
|
+
t.Fatalf("%v: calls=%d err=%v", e, calls, err)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func TestRetry_LoopHonorsTheContextDuringTheWait(t *testing.T) {
|
|
287
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
288
|
+
calls := 0
|
|
289
|
+
_, err := Retry(ctx, DefaultRetryPolicy(), RetryHooks{OnRetry: func(int, int, time.Duration, string) { cancel() }}, func(ctx context.Context, attempt int) (int, error) {
|
|
290
|
+
calls++
|
|
291
|
+
return 0, NewAPIError(503, nil, http.Header{})
|
|
292
|
+
})
|
|
293
|
+
mustAs[*APIUserAbortError](t, err)
|
|
294
|
+
eq(t, calls, 1)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
var _ = reflect.DeepEqual
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package typesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"regexp"
|
|
5
|
+
"runtime"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestDescribeRuntime_ReportsTheGoVersionPlatformAndArch(t *testing.T) {
|
|
11
|
+
// Adapted: the upstream reports node/bun/deno/edge/browser; the Go runtime is always Go.
|
|
12
|
+
twin(t, "runtime.test.ts | describeRuntime reports node with platform and arch here")
|
|
13
|
+
got := describeRuntime()
|
|
14
|
+
eq(t, got, "go/"+strings.TrimPrefix(runtime.Version(), "go")+" ("+runtime.GOOS+"; "+runtime.GOARCH+")")
|
|
15
|
+
if !regexp.MustCompile(`^go/\d+\.\d+(\.\d+)? \(\w+; \w+\)$`).MatchString(got) {
|
|
16
|
+
t.Fatalf("runtime %q", got)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestDescribeRuntime_OtherRuntimes(t *testing.T) {
|
|
21
|
+
skipTwin(t, "JavaScript runtime detection (Bun, Deno, edge, browser, unknown) has no Go counterpart",
|
|
22
|
+
"runtime.test.ts | describeRuntime prefers Bun over node when both are present",
|
|
23
|
+
"runtime.test.ts | describeRuntime recognizes Deno",
|
|
24
|
+
"runtime.test.ts | describeRuntime recognizes edge runtimes by their markers",
|
|
25
|
+
"runtime.test.ts | describeRuntime falls back to browser, then unknown")
|
|
26
|
+
}
|