@rip-lang/server 1.3.115 → 1.3.117

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 (44) hide show
  1. package/README.md +435 -622
  2. package/api.rip +4 -4
  3. package/control/cli.rip +221 -1
  4. package/control/control.rip +9 -0
  5. package/control/lifecycle.rip +6 -1
  6. package/control/watchers.rip +10 -0
  7. package/control/workers.rip +9 -5
  8. package/default.rip +3 -1
  9. package/docs/READ_VALIDATORS.md +656 -0
  10. package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
  11. package/docs/edge/CONTRACTS.md +60 -69
  12. package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
  13. package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
  14. package/edge/config.rip +584 -52
  15. package/edge/forwarding.rip +6 -2
  16. package/edge/metrics.rip +19 -1
  17. package/edge/registry.rip +29 -3
  18. package/edge/router.rip +138 -0
  19. package/edge/runtime.rip +98 -0
  20. package/edge/static.rip +69 -0
  21. package/edge/tls.rip +23 -0
  22. package/edge/upstream.rip +272 -0
  23. package/edge/verify.rip +73 -0
  24. package/middleware.rip +3 -3
  25. package/package.json +2 -2
  26. package/server.rip +775 -393
  27. package/tests/control.rip +18 -0
  28. package/tests/edgefile.rip +165 -0
  29. package/tests/metrics.rip +16 -0
  30. package/tests/proxy.rip +22 -1
  31. package/tests/registry.rip +27 -0
  32. package/tests/router.rip +101 -0
  33. package/tests/runtime_entrypoints.rip +16 -0
  34. package/tests/servers.rip +262 -0
  35. package/tests/static.rip +64 -0
  36. package/tests/streams_clienthello.rip +108 -0
  37. package/tests/streams_index.rip +53 -0
  38. package/tests/streams_pipe.rip +70 -0
  39. package/tests/streams_router.rip +39 -0
  40. package/tests/streams_runtime.rip +38 -0
  41. package/tests/streams_upstream.rip +34 -0
  42. package/tests/upstream.rip +191 -0
  43. package/tests/verify.rip +148 -0
  44. package/tests/watchers.rip +15 -0
@@ -0,0 +1,64 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { serveStaticRoute, buildRedirectResponse } from '../edge/static.rip'
3
+
4
+ # --- helpers ---
5
+
6
+ mockReq = (method = 'GET', accept = 'text/html') ->
7
+ headers: new Headers({ accept })
8
+ method: method
9
+
10
+ mockUrl = (pathname) ->
11
+ new URL("https://example.com#{pathname}")
12
+
13
+ # --- static route tests ---
14
+
15
+ test "serveStaticRoute returns 405 for POST", ->
16
+ req = mockReq('POST')
17
+ route = { static: '/tmp', root: '/tmp', path: '/*' }
18
+ res = serveStaticRoute(req, mockUrl('/foo'), route)
19
+ eq res.status, 405
20
+
21
+ test "serveStaticRoute returns 404 for missing file", ->
22
+ req = mockReq()
23
+ route = { static: '/tmp/nonexistent-dir-rip-test', root: '/tmp/nonexistent-dir-rip-test', path: '/*' }
24
+ res = serveStaticRoute(req, mockUrl('/missing.txt'), route)
25
+ eq res.status, 404
26
+
27
+ test "serveStaticRoute rejects percent-encoded traversal", ->
28
+ req = mockReq()
29
+ route = { static: '/tmp/rip-static-test', root: '/tmp/rip-static-test', path: '/*' }
30
+ url = { pathname: '/%2e%2e/%2e%2e/etc/passwd' }
31
+ res = serveStaticRoute(req, url, route)
32
+ ok res.status is 403 or res.status is 404
33
+
34
+ test "URL-normalized traversal stays safe", ->
35
+ req = mockReq()
36
+ route = { static: '/tmp/rip-static-test', root: '/tmp/rip-static-test', path: '/*' }
37
+ res = serveStaticRoute(req, mockUrl('/../../etc/passwd'), route)
38
+ ok res.status >= 400
39
+
40
+ test "serveStaticRoute SPA fallback requires Accept text/html", ->
41
+ req = mockReq('GET', 'application/json')
42
+ route = { static: '/tmp', root: '/tmp', path: '/*', spa: true }
43
+ res = serveStaticRoute(req, mockUrl('/missing-page'), route)
44
+ eq res.status, 404
45
+
46
+ test "serveStaticRoute HEAD returns 405-safe", ->
47
+ req = mockReq('HEAD')
48
+ route = { static: '/tmp/nonexistent-dir-rip-test', root: '/tmp/nonexistent-dir-rip-test', path: '/*' }
49
+ res = serveStaticRoute(req, mockUrl('/missing.txt'), route)
50
+ eq res.status, 404
51
+
52
+ # --- redirect tests ---
53
+
54
+ test "buildRedirectResponse returns 302 by default", ->
55
+ req = mockReq()
56
+ route = { redirect: { to: 'https://example.com' } }
57
+ res = buildRedirectResponse(req, mockUrl('/old'), route)
58
+ eq res.status, 302
59
+
60
+ test "buildRedirectResponse uses custom status", ->
61
+ req = mockReq()
62
+ route = { redirect: { to: 'https://example.com', status: 301 } }
63
+ res = buildRedirectResponse(req, mockUrl('/old'), route)
64
+ eq res.status, 301
@@ -0,0 +1,108 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { parseSNI } from '../streams/tls_clienthello.rip'
3
+
4
+ buildClientHello = (hostname = 'incus.example.com', opts = {}) ->
5
+ hostBytes = new TextEncoder().encode(hostname)
6
+ sniEntryLen = 1 + 2 + hostBytes.length
7
+ serverNameListLen = sniEntryLen
8
+ sniExtLen = 2 + serverNameListLen
9
+ extensionsLen = if opts.noExtensions then 0 else 4 + sniExtLen
10
+ sessionId = new Uint8Array(0)
11
+ cipherSuites = new Uint8Array([0x00, 0x2f])
12
+ compression = new Uint8Array([0x00])
13
+
14
+ bodyLen = 2 + 32 + 1 + sessionId.length + 2 + cipherSuites.length + 1 + compression.length + 2 + extensionsLen
15
+ recordLen = 4 + bodyLen
16
+ totalLen = 5 + recordLen
17
+ out = new Uint8Array(totalLen)
18
+ i = 0
19
+ out[i++] = opts.contentType or 0x16
20
+ out[i++] = 0x03
21
+ out[i++] = opts.tlsMinor or 0x03
22
+ out[i++] = (recordLen >> 8) & 0xff
23
+ out[i++] = recordLen & 0xff
24
+ out[i++] = opts.handshakeType or 0x01
25
+ out[i++] = (bodyLen >> 16) & 0xff
26
+ out[i++] = (bodyLen >> 8) & 0xff
27
+ out[i++] = bodyLen & 0xff
28
+ out[i++] = 0x03
29
+ out[i++] = opts.clientMinor or 0x03
30
+ for n in [0...32]
31
+ out[i++] = n
32
+ out[i++] = sessionId.length
33
+ out.set(sessionId, i)
34
+ i += sessionId.length
35
+ out[i++] = 0x00
36
+ out[i++] = cipherSuites.length
37
+ out.set(cipherSuites, i)
38
+ i += cipherSuites.length
39
+ out[i++] = compression.length
40
+ out.set(compression, i)
41
+ i += compression.length
42
+ out[i++] = (extensionsLen >> 8) & 0xff
43
+ out[i++] = extensionsLen & 0xff
44
+ unless opts.noExtensions
45
+ out[i++] = 0x00
46
+ out[i++] = 0x00
47
+ out[i++] = (sniExtLen >> 8) & 0xff
48
+ out[i++] = sniExtLen & 0xff
49
+ out[i++] = (serverNameListLen >> 8) & 0xff
50
+ out[i++] = serverNameListLen & 0xff
51
+ out[i++] = 0x00
52
+ out[i++] = (hostBytes.length >> 8) & 0xff
53
+ out[i++] = hostBytes.length & 0xff
54
+ out.set(hostBytes, i)
55
+ i += hostBytes.length
56
+ out
57
+
58
+ test "parseSNI extracts hostname from TLS 1.2 ClientHello", ->
59
+ hello = buildClientHello('incus.example.com', tlsMinor: 0x03, clientMinor: 0x03)
60
+ parsed = parseSNI(hello)
61
+ eq parsed.ok, true
62
+ eq parsed.sni, 'incus.example.com'
63
+
64
+ test "parseSNI extracts hostname from TLS 1.3-style ClientHello", ->
65
+ hello = buildClientHello('api.example.com', tlsMinor: 0x03, clientMinor: 0x03)
66
+ parsed = parseSNI(hello)
67
+ eq parsed.ok, true
68
+ eq parsed.sni, 'api.example.com'
69
+
70
+ test "parseSNI lowercases hostname", ->
71
+ hello = buildClientHello('InCus.Example.Com')
72
+ parsed = parseSNI(hello)
73
+ eq parsed.sni, 'incus.example.com'
74
+
75
+ test "parseSNI returns need_more_bytes for short buffer", ->
76
+ parsed = parseSNI(new Uint8Array([0x16, 0x03, 0x03]))
77
+ eq parsed.ok, false
78
+ eq parsed.code, 'need_more_bytes'
79
+
80
+ test "parseSNI rejects wrong record type", ->
81
+ hello = buildClientHello('incus.example.com', contentType: 0x17)
82
+ parsed = parseSNI(hello)
83
+ eq parsed.ok, false
84
+ eq parsed.code, 'bad_record_type'
85
+
86
+ test "parseSNI rejects no-extensions hello as no_sni", ->
87
+ hello = buildClientHello('incus.example.com', noExtensions: true)
88
+ parsed = parseSNI(hello)
89
+ eq parsed.ok, false
90
+ eq parsed.code, 'no_sni'
91
+
92
+ test "parseSNI rejects oversized ClientHello", ->
93
+ huge = new Uint8Array(5)
94
+ huge[0] = 0x16
95
+ huge[1] = 0x03
96
+ huge[2] = 0x03
97
+ huge[3] = 0x40
98
+ huge[4] = 0x01
99
+ parsed = parseSNI(huge)
100
+ eq parsed.ok, false
101
+ eq parsed.code, 'client_hello_too_large'
102
+
103
+ test "parseSNI rejects malformed extension bounds", ->
104
+ hello = buildClientHello('incus.example.com')
105
+ hello[54] = 0xff
106
+ hello[55] = 0xff
107
+ parsed = parseSNI(hello)
108
+ ok parsed.ok is false
@@ -0,0 +1,53 @@
1
+ import { test, eq } from '../test.rip'
2
+ import { buildStreamRuntime, resolveHandshakeTarget } from '../streams/index.rip'
3
+
4
+ test "resolveHandshakeTarget falls through to http fallback when no route matches", ->
5
+ runtime = buildStreamRuntime(
6
+ streamUpstreams:
7
+ incus:
8
+ targets: [{ host: '127.0.0.1', port: 8443 }]
9
+ streams: [
10
+ { id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
11
+ ]
12
+ )
13
+ result = resolveHandshakeTarget(runtime, 443, 'app.example.com',
14
+ 443:
15
+ hostname: '127.0.0.1'
16
+ port: 9443
17
+ )
18
+ eq result.kind, 'fallback'
19
+ eq result.target.port, 9443
20
+
21
+ test "resolveHandshakeTarget prefers configured stream routes over fallback", ->
22
+ runtime = buildStreamRuntime(
23
+ streamUpstreams:
24
+ incus:
25
+ targets: [{ host: '127.0.0.1', port: 8443 }]
26
+ streams: [
27
+ { id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
28
+ ]
29
+ )
30
+ result = resolveHandshakeTarget(runtime, 443, 'incus.example.com',
31
+ 443:
32
+ hostname: '127.0.0.1'
33
+ port: 9443
34
+ )
35
+ eq result.kind, 'stream'
36
+ eq result.target.port, 8443
37
+
38
+ test "resolveHandshakeTarget rejects when stream route matches but upstream is unavailable", ->
39
+ runtime = buildStreamRuntime(
40
+ streamUpstreams:
41
+ incus:
42
+ targets: []
43
+ streams: [
44
+ { id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
45
+ ]
46
+ )
47
+ result = resolveHandshakeTarget(runtime, 443, 'incus.example.com',
48
+ 443:
49
+ hostname: '127.0.0.1'
50
+ port: 9443
51
+ )
52
+ eq result.kind, 'reject'
53
+ eq result.code, 'no_available_target'
@@ -0,0 +1,70 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import { createPipeState, writeChunk, flushPending } from '../streams/pipe.rip'
3
+
4
+ fakeSocket = (returns = []) ->
5
+ writes = []
6
+ idx = 0
7
+ paused = 0
8
+ resumed = 0
9
+ {
10
+ writes
11
+ write: (data) ->
12
+ writes.push(new Uint8Array(data))
13
+ ret = returns[idx]
14
+ idx++
15
+ if ret? then ret else data.byteLength
16
+ pause: -> paused++
17
+ resume: -> resumed++
18
+ pausedCount: -> paused
19
+ resumedCount: -> resumed
20
+ }
21
+
22
+ test "writeChunk succeeds when full write completes", ->
23
+ sock = fakeSocket()
24
+ state = createPipeState()
25
+ res = writeChunk(sock, state, new Uint8Array([1, 2, 3]))
26
+ eq res.needDrain, false
27
+ eq state.pending.byteLength, 0
28
+
29
+ test "writeChunk buffers remainder on partial write", ->
30
+ sock = fakeSocket([1])
31
+ state = createPipeState()
32
+ res = writeChunk(sock, state, new Uint8Array([1, 2, 3]))
33
+ eq res.needDrain, true
34
+ eq state.pending.byteLength, 2
35
+ eq Array.from(state.pending), [2, 3]
36
+
37
+ test "flushPending clears buffer when write completes", ->
38
+ sock = fakeSocket()
39
+ state = createPipeState()
40
+ state.pending = new Uint8Array([4, 5])
41
+ state.waitingForDrain = true
42
+ res = flushPending(sock, state)
43
+ eq res.needDrain, false
44
+ eq state.pending.byteLength, 0
45
+
46
+ test "flushPending keeps remainder on partial write", ->
47
+ sock = fakeSocket([1])
48
+ state = createPipeState()
49
+ state.pending = new Uint8Array([4, 5, 6])
50
+ state.waitingForDrain = true
51
+ res = flushPending(sock, state)
52
+ eq res.needDrain, true
53
+ eq Array.from(state.pending), [5, 6]
54
+
55
+ test "writeChunk appends new data behind existing pending buffer", ->
56
+ sock = fakeSocket()
57
+ state = createPipeState()
58
+ state.pending = new Uint8Array([9, 9])
59
+ state.waitingForDrain = true
60
+ res = writeChunk(sock, state, new Uint8Array([1, 2]))
61
+ eq res.needDrain, true
62
+ eq res.wrote, 0
63
+ eq Array.from(state.pending), [9, 9, 1, 2]
64
+
65
+ test "writeChunk reports closed socket on -1", ->
66
+ sock = fakeSocket([-1])
67
+ state = createPipeState()
68
+ res = writeChunk(sock, state, new Uint8Array([1]))
69
+ eq res.closed, true
70
+ eq res.ok, false
@@ -0,0 +1,39 @@
1
+ import { test, eq } from '../test.rip'
2
+ import { compileStreamTable, matchStreamRoute, describeStreamRoute } from '../streams/router.rip'
3
+
4
+ test "compileStreamTable expands sni patterns", ->
5
+ table = compileStreamTable([
6
+ { id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com', '*.example.com'], upstream: 'incus' }
7
+ ])
8
+ eq table.routes.length, 2
9
+
10
+ test "matchStreamRoute prefers exact sni", ->
11
+ table = compileStreamTable([
12
+ { id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
13
+ { id: 'exact', order: 1, listen: 8443, sni: ['incus.example.com'], upstream: 'exact' }
14
+ ])
15
+ eq matchStreamRoute(table, 8443, 'incus.example.com').upstream, 'exact'
16
+
17
+ test "matchStreamRoute matches wildcard sni", ->
18
+ table = compileStreamTable([
19
+ { id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
20
+ ])
21
+ eq matchStreamRoute(table, 8443, 'api.example.com').upstream, 'wild'
22
+
23
+ test "matchStreamRoute wildcard is single-label only", ->
24
+ table = compileStreamTable([
25
+ { id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
26
+ ])
27
+ eq matchStreamRoute(table, 8443, 'a.b.example.com'), null
28
+
29
+ test "matchStreamRoute filters by listen port", ->
30
+ table = compileStreamTable([
31
+ { id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
32
+ ])
33
+ eq matchStreamRoute(table, 443, 'incus.example.com'), null
34
+
35
+ test "describeStreamRoute summarizes route", ->
36
+ table = compileStreamTable([
37
+ { id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
38
+ ])
39
+ eq describeStreamRoute(table.routes[0]), '8443 incus.example.com -> incus'
@@ -0,0 +1,38 @@
1
+ import { test, eq } from '../test.rip'
2
+ import { createStreamRuntime, describeStreamRuntime, buildStreamConfigInfo, streamUsesListenPort } from '../streams/runtime.rip'
3
+
4
+ test "createStreamRuntime starts empty", ->
5
+ runtime = createStreamRuntime()
6
+ eq runtime.inflight, 0
7
+ eq runtime.listeners.size, 0
8
+
9
+ test "describeStreamRuntime returns summary", ->
10
+ runtime = createStreamRuntime()
11
+ runtime.inflight = 2
12
+ runtime.retiredAt = '2026-01-01T00:00:00.000Z'
13
+ runtime.listeners.set(8443, {})
14
+ summary = describeStreamRuntime(runtime)
15
+ eq summary.inflight, 2
16
+ eq summary.listeners[0], 8443
17
+
18
+ test "buildStreamConfigInfo reports counts and descriptions", ->
19
+ info = buildStreamConfigInfo(
20
+ streamUpstreams:
21
+ incus: {}
22
+ streams: [
23
+ { id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
24
+ ]
25
+ )
26
+ eq info.streamCounts.streamUpstreams, 1
27
+ eq info.streamCounts.streams, 1
28
+ eq info.activeStreamRouteDescriptions[0], '8443 incus.example.com -> incus'
29
+
30
+ test "streamUsesListenPort detects shared listener ports", ->
31
+ runtime = createStreamRuntime(null, null,
32
+ routes: [
33
+ { listen: 443, sni: 'incus.example.com', upstream: 'incus' }
34
+ { listen: 8443, sni: 'db.example.com', upstream: 'db' }
35
+ ]
36
+ )
37
+ eq streamUsesListenPort(runtime, 443), true
38
+ eq streamUsesListenPort(runtime, 80), false
@@ -0,0 +1,34 @@
1
+ import { test, eq } from '../test.rip'
2
+ import { createStreamUpstreamPool, addStreamUpstream, getStreamUpstream, selectStreamTarget, openStreamConnection, releaseStreamConnection } from '../streams/upstream.rip'
3
+
4
+ test "addStreamUpstream registers targets", ->
5
+ pool = createStreamUpstreamPool()
6
+ upstream = addStreamUpstream(pool, 'incus',
7
+ targets: [{ targetId: '127.0.0.1:8443', host: '127.0.0.1', port: 8443 }]
8
+ )
9
+ eq getStreamUpstream(pool, 'incus').targets.length, 1
10
+ eq upstream.connectTimeoutMs, 5000
11
+
12
+ test "selectStreamTarget prefers least connections", ->
13
+ pool = createStreamUpstreamPool()
14
+ upstream = addStreamUpstream(pool, 'incus',
15
+ targets: [
16
+ { targetId: 'a:1', host: 'a', port: 1 }
17
+ { targetId: 'b:1', host: 'b', port: 1 }
18
+ ]
19
+ )
20
+ upstream.targets[0].activeConnections = 3
21
+ upstream.targets[1].activeConnections = 1
22
+ eq selectStreamTarget(upstream).targetId, 'b:1'
23
+
24
+ test "open and release stream connection update counters", ->
25
+ pool = createStreamUpstreamPool()
26
+ upstream = addStreamUpstream(pool, 'incus',
27
+ targets: [{ targetId: 'a:1', host: 'a', port: 1 }]
28
+ )
29
+ target = upstream.targets[0]
30
+ openStreamConnection(target)
31
+ eq target.activeConnections, 1
32
+ eq target.totalConnections, 1
33
+ releaseStreamConnection(target)
34
+ eq target.activeConnections, 0
@@ -0,0 +1,191 @@
1
+ import { test, eq, ok } from '../test.rip'
2
+ import {
3
+ createUpstreamPool
4
+ addUpstream
5
+ getUpstream
6
+ listUpstreams
7
+ removeUpstream
8
+ selectTarget
9
+ markTargetBusy
10
+ releaseTarget
11
+ shouldRetry
12
+ computeRetryDelayMs
13
+ updateTargetHealth
14
+ } from '../edge/upstream.rip'
15
+
16
+ test "createUpstreamPool starts empty", ->
17
+ pool = createUpstreamPool()
18
+ eq pool.upstreams.size, 0
19
+
20
+ test "addUpstream normalizes targets and defaults", ->
21
+ pool = createUpstreamPool()
22
+ upstream = addUpstream(pool, 'app',
23
+ targets: ['http://127.0.0.1:3000']
24
+ )
25
+ eq pool.upstreams.size, 1
26
+ eq upstream.targets.length, 1
27
+ eq upstream.targets[0].healthy, true
28
+ eq upstream.targets[0].circuitState, 'closed'
29
+
30
+ test "listUpstreams returns added upstreams", ->
31
+ pool = createUpstreamPool()
32
+ addUpstream(pool, 'app', targets: ['http://127.0.0.1:3000'])
33
+ eq listUpstreams(pool).length, 1
34
+
35
+ test "removeUpstream deletes entry", ->
36
+ pool = createUpstreamPool()
37
+ addUpstream(pool, 'app', targets: ['http://127.0.0.1:3000'])
38
+ removeUpstream(pool, 'app')
39
+ eq getUpstream(pool, 'app'), null
40
+
41
+ test "selectTarget picks least inflight target", ->
42
+ pool = createUpstreamPool()
43
+ upstream = addUpstream(pool, 'app',
44
+ targets: ['http://a', 'http://b']
45
+ )
46
+ upstream.targets[0].inflight = 2
47
+ upstream.targets[1].inflight = 0
48
+ eq selectTarget(upstream).url, 'http://b'
49
+
50
+ test "selectTarget falls back to latency tie-break", ->
51
+ pool = createUpstreamPool()
52
+ upstream = addUpstream(pool, 'app',
53
+ targets: ['http://a', 'http://b']
54
+ )
55
+ upstream.targets[0].latencyEwma = 50
56
+ upstream.targets[1].latencyEwma = 10
57
+ eq selectTarget(upstream).url, 'http://b'
58
+
59
+ test "selectTarget returns null when all targets unhealthy", ->
60
+ pool = createUpstreamPool()
61
+ upstream = addUpstream(pool, 'app',
62
+ targets: ['http://a', 'http://b']
63
+ )
64
+ upstream.targets[0].healthy = false
65
+ upstream.targets[1].healthy = false
66
+ eq selectTarget(upstream), null
67
+
68
+ test "markTargetBusy increments inflight", ->
69
+ pool = createUpstreamPool()
70
+ upstream = addUpstream(pool, 'app', targets: ['http://a'])
71
+ target = upstream.targets[0]
72
+ markTargetBusy(target)
73
+ eq target.inflight, 1
74
+
75
+ test "releaseTarget decrements inflight and updates ewma", ->
76
+ pool = createUpstreamPool()
77
+ upstream = addUpstream(pool, 'app', targets: ['http://a'])
78
+ target = upstream.targets[0]
79
+ markTargetBusy(target)
80
+ releaseTarget(target, 100, true, pool)
81
+ eq target.inflight, 0
82
+ ok target.latencyEwma > 0
83
+
84
+ test "updateTargetHealth marks target unhealthy after threshold", ->
85
+ pool = createUpstreamPool(nowFn: -> 1000, randomFn: -> 0)
86
+ upstream = addUpstream(pool, 'app',
87
+ targets: ['http://a']
88
+ healthCheck:
89
+ unhealthyThreshold: 2
90
+ )
91
+ target = upstream.targets[0]
92
+ updateTargetHealth(target, false, pool)
93
+ eq target.healthy, true
94
+ updateTargetHealth(target, false, pool)
95
+ eq target.healthy, false
96
+
97
+ test "updateTargetHealth restores healthy after success threshold", ->
98
+ pool = createUpstreamPool(nowFn: -> 1000, randomFn: -> 0)
99
+ upstream = addUpstream(pool, 'app',
100
+ targets: ['http://a']
101
+ healthCheck:
102
+ unhealthyThreshold: 1
103
+ healthyThreshold: 2
104
+ )
105
+ target = upstream.targets[0]
106
+ updateTargetHealth(target, false, pool)
107
+ eq target.healthy, false
108
+ updateTargetHealth(target, true, pool)
109
+ eq target.healthy, false
110
+ updateTargetHealth(target, true, pool)
111
+ eq target.healthy, true
112
+
113
+ test "releaseTarget opens circuit after enough failures", ->
114
+ pool = createUpstreamPool(nowFn: -> 1234, randomFn: -> 0)
115
+ upstream = addUpstream(pool, 'app',
116
+ targets: ['http://a']
117
+ circuit:
118
+ minRequests: 3
119
+ errorThreshold: 0.5
120
+ cooldownMs: 1000
121
+ )
122
+ target = upstream.targets[0]
123
+ for _ in [1..3]
124
+ markTargetBusy(target)
125
+ releaseTarget(target, 10, false, pool)
126
+ eq target.circuitState, 'open'
127
+ eq target.circuitOpenedAt, 1234
128
+
129
+ test "selectTarget reopens half-open target after cooldown", ->
130
+ now = 0
131
+ pool = createUpstreamPool(nowFn: -> now, randomFn: -> 0)
132
+ upstream = addUpstream(pool, 'app',
133
+ targets: ['http://a']
134
+ circuit:
135
+ minRequests: 1
136
+ cooldownMs: 100
137
+ jitterRatio: 0
138
+ )
139
+ target = upstream.targets[0]
140
+ markTargetBusy(target)
141
+ releaseTarget(target, 10, false, pool)
142
+ eq selectTarget(upstream, pool.nowFn), null
143
+ now = 150
144
+ selected = selectTarget(upstream, pool.nowFn)
145
+ eq selected.url, 'http://a'
146
+ eq target.circuitState, 'half-open'
147
+
148
+ test "half-open success closes circuit", ->
149
+ pool = createUpstreamPool(nowFn: -> 500, randomFn: -> 0)
150
+ upstream = addUpstream(pool, 'app',
151
+ targets: ['http://a']
152
+ circuit:
153
+ minRequests: 1
154
+ cooldownMs: 100
155
+ jitterRatio: 0
156
+ )
157
+ target = upstream.targets[0]
158
+ markTargetBusy(target)
159
+ releaseTarget(target, 10, false, pool)
160
+ target.circuitOpenedAt = 0
161
+ selected = selectTarget(upstream, pool.nowFn)
162
+ markTargetBusy(selected)
163
+ releaseTarget(selected, 5, true, pool)
164
+ eq target.circuitState, 'closed'
165
+
166
+ test "shouldRetry honors method status and body state", ->
167
+ retry = attempts: 2, retryOn: [502, 503], retryMethods: ['GET']
168
+ ok shouldRetry(retry, 'GET', 502, false)
169
+ eq shouldRetry(retry, 'POST', 502, false), false
170
+ eq shouldRetry(retry, 'GET', 500, false), false
171
+ eq shouldRetry(retry, 'GET', 502, true), false
172
+
173
+ test "computeRetryDelayMs adds bounded jitter", ->
174
+ delay = computeRetryDelayMs({ backoffMs: 100 }, 2, -> 0)
175
+ eq delay, 200
176
+
177
+ test "updateTargetHealth records last check timestamp", ->
178
+ pool = createUpstreamPool(nowFn: -> 42, randomFn: -> 0)
179
+ upstream = addUpstream(pool, 'app', targets: ['http://a'])
180
+ target = upstream.targets[0]
181
+ updateTargetHealth(target, true, pool)
182
+ eq target.lastCheckAt, 42
183
+
184
+ test "updateTargetHealth can trip half-open target back to open", ->
185
+ pool = createUpstreamPool(nowFn: -> 99, randomFn: -> 0)
186
+ upstream = addUpstream(pool, 'app', targets: ['http://a'])
187
+ target = upstream.targets[0]
188
+ target.circuitState = 'half-open'
189
+ updateTargetHealth(target, false, pool)
190
+ eq target.circuitState, 'open'
191
+ eq target.circuitOpenedAt, 99