@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.
Files changed (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. 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
+ }