tep 0.11.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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,556 @@
1
+ require_relative "helper"
2
+ require "json"
3
+
4
+ # Tep::Proxy -- HTTP reverse-proxy battery (chunk 6.1, non-streaming).
5
+ #
6
+ # Same self-calling shape as test_http.rb: the app boots both the
7
+ # "upstream" endpoints (/ping, /echo_body, /headers_back, /teapot)
8
+ # and proxy routes that forward to 127.0.0.1:<own-port>. The proxy
9
+ # instance's upstream is fixed at construction, so each proxy route
10
+ # builds its Tep::Proxy subclass inside the handler with the runtime
11
+ # port from a path capture (the harness can't tell a load-time
12
+ # constructor its own port).
13
+ #
14
+ # Runs under Tep::Server::Scheduled with workers=1 -- the only shape
15
+ # where a handler can make an outbound call back to its own server
16
+ # under cooperative I/O (see docs/MACOS-CONCURRENCY.md, test_http.rb).
17
+ class TestProxy < TepTest
18
+ app_source <<~RB
19
+ require 'sinatra'
20
+
21
+ set :scheduler, :scheduled
22
+ set :workers, 1
23
+
24
+ # ---- upstream endpoints (what the proxies forward to) ----
25
+
26
+ get '/ping' do
27
+ "pong"
28
+ end
29
+
30
+ post '/echo_body' do
31
+ res.headers["Content-Type"] = "text/plain"
32
+ req.raw_body
33
+ end
34
+
35
+ get '/headers_back' do
36
+ "x-custom=" + req.req_headers["x-custom"]
37
+ end
38
+
39
+ get '/teapot' do
40
+ res.set_status(418)
41
+ "i'm a teapot"
42
+ end
43
+
44
+ get '/sets_header' do
45
+ res.headers["X-Upstream"] = "from-upstream"
46
+ "ok"
47
+ end
48
+
49
+ # ---- proxy subclasses (the overridable-hook lowering target) ----
50
+
51
+ # Forward to a fixed upstream path regardless of inbound path.
52
+ class PingProxy < Tep::Proxy
53
+ def rewrite_path(path)
54
+ "/ping"
55
+ end
56
+ end
57
+
58
+ class TeapotProxy < Tep::Proxy
59
+ def rewrite_path(path)
60
+ "/teapot"
61
+ end
62
+ end
63
+
64
+ class HeaderBackProxy < Tep::Proxy
65
+ def rewrite_path(path)
66
+ "/headers_back"
67
+ end
68
+ end
69
+
70
+ class SetsHeaderProxy < Tep::Proxy
71
+ def rewrite_path(path)
72
+ "/sets_header"
73
+ end
74
+ end
75
+
76
+ # Inject a header into the upstream request.
77
+ class InjectProxy < Tep::Proxy
78
+ def rewrite_path(path)
79
+ "/headers_back"
80
+ end
81
+ def before_forward(req, res, ureq)
82
+ ureq.set_header("X-Custom", "injected-by-proxy")
83
+ false
84
+ end
85
+ end
86
+
87
+ # Short-circuit: never reach upstream.
88
+ class GuardProxy < Tep::Proxy
89
+ def before_forward(req, res, ureq)
90
+ res.set_status(403)
91
+ res.set_body("denied by proxy")
92
+ true
93
+ end
94
+ end
95
+
96
+ # Stamp the response on the way back out.
97
+ class StampProxy < Tep::Proxy
98
+ def rewrite_path(path)
99
+ "/ping"
100
+ end
101
+ def after_forward(req, ures, res)
102
+ res.headers["X-Proxied"] = "yes"
103
+ 0
104
+ end
105
+ end
106
+
107
+ # Pass the request body straight through to /echo_body.
108
+ class EchoProxy < Tep::Proxy
109
+ def rewrite_path(path)
110
+ "/echo_body"
111
+ end
112
+ end
113
+
114
+ # ---- proxy mount routes (build with runtime port) ----
115
+
116
+ get '/p/ping/:port' do
117
+ PingProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
118
+ res.body
119
+ end
120
+
121
+ get '/p/teapot/:port' do
122
+ TeapotProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
123
+ res.body
124
+ end
125
+
126
+ get '/p/inject/:port' do
127
+ InjectProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
128
+ res.body
129
+ end
130
+
131
+ get '/p/guard/:port' do
132
+ GuardProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
133
+ res.body
134
+ end
135
+
136
+ get '/p/stamp/:port' do
137
+ StampProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
138
+ res.body
139
+ end
140
+
141
+ get '/p/upstreamhdr/:port' do
142
+ SetsHeaderProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
143
+ res.body
144
+ end
145
+
146
+ post '/p/echo/:port' do
147
+ EchoProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
148
+ res.body
149
+ end
150
+
151
+ # Dead upstream port -> connect failure -> 502.
152
+ get '/p/deadport' do
153
+ PingProxy.new("http://127.0.0.1:1").handle(req, res)
154
+ res.body
155
+ end
156
+ RB
157
+
158
+ def test_forwards_get_and_returns_upstream_body
159
+ res = get("/p/ping/#{@port}")
160
+ assert_equal "200", res.code
161
+ assert_equal "pong", res.body
162
+ end
163
+
164
+ def test_propagates_upstream_status
165
+ res = get("/p/teapot/#{@port}")
166
+ assert_equal "418", res.code
167
+ assert_equal "i'm a teapot", res.body
168
+ end
169
+
170
+ def test_before_forward_can_inject_upstream_header
171
+ res = get("/p/inject/#{@port}")
172
+ assert_equal "200", res.code
173
+ assert_equal "x-custom=injected-by-proxy", res.body
174
+ end
175
+
176
+ def test_before_forward_short_circuits
177
+ res = get("/p/guard/#{@port}")
178
+ assert_equal "403", res.code
179
+ assert_equal "denied by proxy", res.body
180
+ end
181
+
182
+ def test_after_forward_can_stamp_response
183
+ res = get("/p/stamp/#{@port}")
184
+ assert_equal "200", res.code
185
+ assert_equal "pong", res.body
186
+ assert_equal "yes", res["X-Proxied"]
187
+ end
188
+
189
+ def test_propagates_upstream_response_headers
190
+ res = get("/p/upstreamhdr/#{@port}")
191
+ assert_equal "200", res.code
192
+ assert_equal "from-upstream", res["X-Upstream"]
193
+ end
194
+
195
+ def test_forwards_post_body
196
+ res = post("/p/echo/#{@port}", "round trip body")
197
+ assert_equal "200", res.code
198
+ assert_equal "round trip body", res.body
199
+ end
200
+
201
+ def test_connect_failure_maps_to_502
202
+ res = get("/p/deadport")
203
+ assert_equal "502", res.code
204
+ end
205
+ end
206
+
207
+ # Tep::Proxy 6.4: per-request upstream routing via pick_upstream(req).
208
+ # Two faux backends (/srv-a/info, /srv-b/info) on the same server are
209
+ # routed through a Router proxy whose pick_upstream branches by path.
210
+ # Demonstrates the override path and that the default (returning
211
+ # @upstream) is preserved when not overridden.
212
+ class TestProxyMultiUpstream < TepTest
213
+ app_source <<~RB
214
+ require 'sinatra'
215
+
216
+ set :scheduler, :scheduled
217
+ set :workers, 1
218
+
219
+ # Two upstream "backends" on the same server, distinguished by
220
+ # path prefix. In a real deployment these would be separate hosts;
221
+ # the test framework runs one app per class so we collapse them
222
+ # onto distinct routes that pick_upstream + rewrite_path can
223
+ # treat as logically separate upstreams.
224
+ get '/srv-a/info' do
225
+ "from-a"
226
+ end
227
+ get '/srv-b/info' do
228
+ "from-b"
229
+ end
230
+
231
+ # Routes /p/route/:port/a -> srv-a's /info, /p/route/:port/b ->
232
+ # srv-b's /info. pick_upstream picks the BASE URL (host+port +
233
+ # the fixed /srv-X prefix); rewrite_path produces the suffix.
234
+ # Composed: pick_upstream(req) + rewrite_path(raw_path).
235
+ class Router < Tep::Proxy
236
+ def pick_upstream(req)
237
+ if Tep.str_find(req.path, "/a", 0) >= 0
238
+ @upstream + "/srv-a"
239
+ else
240
+ @upstream + "/srv-b"
241
+ end
242
+ end
243
+ def rewrite_path(path)
244
+ "/info"
245
+ end
246
+ end
247
+
248
+ get '/p/route/:port/:where' do
249
+ Router.new("http://127.0.0.1:" + params[:port]).handle(req, res)
250
+ res.body
251
+ end
252
+ RB
253
+
254
+ def test_pick_upstream_routes_to_srv_a
255
+ res = get("/p/route/#{@port}/a")
256
+ assert_equal "200", res.code
257
+ assert_equal "from-a", res.body
258
+ end
259
+
260
+ def test_pick_upstream_routes_to_srv_b
261
+ res = get("/p/route/#{@port}/b")
262
+ assert_equal "200", res.code
263
+ assert_equal "from-b", res.body
264
+ end
265
+ end
266
+
267
+ # Tep::Proxy 6.6: body size caps. max_request_body_bytes rejects
268
+ # oversize POSTs with 413 before any upstream call; max_response_body_bytes
269
+ # rejects oversize upstream responses with 502 + proxy_error JSON.
270
+ class TestProxyBodyCaps < TepTest
271
+ app_source <<~RB
272
+ require 'sinatra'
273
+
274
+ set :scheduler, :scheduled
275
+ set :workers, 1
276
+
277
+ # Two upstream endpoints with hardcoded sizes -- 50-byte (under
278
+ # the 200-byte response cap) and 500-byte (over).
279
+ get '/upstream/small' do
280
+ res.headers["Content-Type"] = "application/octet-stream"
281
+ "x" * 50
282
+ end
283
+
284
+ get '/upstream/large' do
285
+ res.headers["Content-Type"] = "application/octet-stream"
286
+ "x" * 500
287
+ end
288
+
289
+ post '/upstream/echo' do
290
+ res.headers["Content-Type"] = "application/octet-stream"
291
+ req.raw_body
292
+ end
293
+
294
+ # Proxies with tiny caps to make over/under testable. 100-byte
295
+ # request cap; 200-byte response cap. Each one extends Tep::Proxy
296
+ # DIRECTLY (not via a shared intermediate parent) -- spinel's
297
+ # widening over an intermediate-class initialize that sets the
298
+ # new attrs lets the upstream dispatch widen and Tep::Http.send_req
299
+ # returns status=0 / connect-failure 502. Three direct subclasses
300
+ # type-pin cleanly; the duplicated initialize is a deliberate
301
+ # workaround.
302
+ class TinyEchoProxy < Tep::Proxy
303
+ def initialize(upstream)
304
+ super
305
+ self.max_request_body_bytes = 100
306
+ self.max_response_body_bytes = 200
307
+ end
308
+ def rewrite_path(path)
309
+ "/upstream/echo"
310
+ end
311
+ end
312
+
313
+ class TinySmallProxy < Tep::Proxy
314
+ def initialize(upstream)
315
+ super
316
+ self.max_request_body_bytes = 100
317
+ self.max_response_body_bytes = 200
318
+ end
319
+ def rewrite_path(path)
320
+ "/upstream/small"
321
+ end
322
+ end
323
+
324
+ class TinyLargeProxy < Tep::Proxy
325
+ def initialize(upstream)
326
+ super
327
+ self.max_request_body_bytes = 100
328
+ self.max_response_body_bytes = 200
329
+ end
330
+ def rewrite_path(path)
331
+ "/upstream/large"
332
+ end
333
+ end
334
+
335
+ post '/p/tiny-echo/:port' do
336
+ TinyEchoProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
337
+ res.body
338
+ end
339
+
340
+ get '/p/tiny-small/:port' do
341
+ TinySmallProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
342
+ res.body
343
+ end
344
+
345
+ get '/p/tiny-large/:port' do
346
+ TinyLargeProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
347
+ res.body
348
+ end
349
+ RB
350
+
351
+ def test_request_under_cap_passes
352
+ res = post("/p/tiny-echo/#{@port}", "x" * 50)
353
+ assert_equal "200", res.code
354
+ assert_equal "x" * 50, res.body
355
+ end
356
+
357
+ def test_request_over_cap_returns_413
358
+ res = post("/p/tiny-echo/#{@port}", "x" * 500)
359
+ assert_equal "413", res.code
360
+ body = JSON.parse(res.body)
361
+ assert_equal "payload_too_large", body["error"]["type"]
362
+ assert_match(/proxy cap of 100 bytes/, body["error"]["message"])
363
+ end
364
+
365
+ def test_response_under_cap_passes
366
+ res = get("/p/tiny-small/#{@port}")
367
+ assert_equal "200", res.code
368
+ assert_equal 50, res.body.length
369
+ end
370
+
371
+ def test_response_over_cap_returns_502
372
+ res = get("/p/tiny-large/#{@port}")
373
+ assert_equal "502", res.code
374
+ refute_empty res.body, "expected an error-shape body, got empty (502 from connect failure path?)"
375
+ body = JSON.parse(res.body)
376
+ assert_equal "upstream_body_too_large", body["error"]["type"]
377
+ assert_match(/upstream response body exceeds proxy cap of 200 bytes/,
378
+ body["error"]["message"])
379
+ end
380
+ end
381
+
382
+ # Tep::Proxy 6.5: retries on transient upstream failures.
383
+ class TestProxyRetries < TepTest
384
+ app_source <<~RB
385
+ require 'sinatra'
386
+
387
+ set :scheduler, :scheduled
388
+ set :workers, 1
389
+
390
+ # Upstream with a per-process attempt counter -- returns 503 on
391
+ # the first hit, 200 on every subsequent hit. Resets via a
392
+ # /reset route between tests so each test gets a clean slate.
393
+ class FlakyState
394
+ attr_accessor :hits
395
+ def initialize; @hits = 0; end
396
+ end
397
+ STATE = FlakyState.new
398
+
399
+ get '/upstream/flaky' do
400
+ STATE.hits = STATE.hits + 1
401
+ if STATE.hits == 1
402
+ res.set_status(503)
403
+ return "{\\"error\\":\\"unavailable\\"}"
404
+ end
405
+ res.headers["Content-Type"] = "text/plain"
406
+ "ok"
407
+ end
408
+
409
+ get '/upstream/always_503' do
410
+ res.set_status(503)
411
+ "{\\"error\\":\\"unavailable\\"}"
412
+ end
413
+
414
+ post '/reset_flaky' do
415
+ STATE.hits = 0
416
+ "0"
417
+ end
418
+
419
+ # Direct-subclass shape (same workaround as the body-caps tests:
420
+ # an intermediate-class initialize that sets new attrs widens
421
+ # incorrectly across multiple grandchildren).
422
+ class FlakyProxy < Tep::Proxy
423
+ def retry_policy(req)
424
+ p = Tep::Proxy::RetryPolicy.new
425
+ p.max_attempts = 3
426
+ # base_backoff_seconds stays 0 (test-friendly).
427
+ p
428
+ end
429
+ def rewrite_path(path)
430
+ "/upstream/flaky"
431
+ end
432
+ end
433
+
434
+ class GiveUpProxy < Tep::Proxy
435
+ def retry_policy(req)
436
+ p = Tep::Proxy::RetryPolicy.new
437
+ p.max_attempts = 2 # 1 retry; both hit the always-503
438
+ p
439
+ end
440
+ def rewrite_path(path)
441
+ "/upstream/always_503"
442
+ end
443
+ end
444
+
445
+ class NoRetryProxy < Tep::Proxy
446
+ # Default retry_policy (max_attempts=1) -- 503 surfaces as 503.
447
+ def rewrite_path(path)
448
+ "/upstream/flaky"
449
+ end
450
+ end
451
+
452
+ # Same flaky upstream but with an explicit 200ms backoff. Used
453
+ # to verify the ms-grained sleep actually fires.
454
+ class FlakyBackoffProxy < Tep::Proxy
455
+ def retry_policy(req)
456
+ p = Tep::Proxy::RetryPolicy.new
457
+ p.max_attempts = 2
458
+ p.base_backoff_ms = 200
459
+ p
460
+ end
461
+ def rewrite_path(path)
462
+ "/upstream/flaky"
463
+ end
464
+ end
465
+
466
+ # Same again but uses the Float-seconds convenience setter to
467
+ # prove it stores the right ms (0.2 -> 200ms).
468
+ class FlakyBackoffSecsProxy < Tep::Proxy
469
+ def retry_policy(req)
470
+ p = Tep::Proxy::RetryPolicy.new
471
+ p.max_attempts = 2
472
+ p.base_backoff_secs = 0.2
473
+ p
474
+ end
475
+ def rewrite_path(path)
476
+ "/upstream/flaky"
477
+ end
478
+ end
479
+
480
+ get '/p/flaky/:port' do
481
+ FlakyProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
482
+ res.body
483
+ end
484
+
485
+ get '/p/giveup/:port' do
486
+ GiveUpProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
487
+ res.body
488
+ end
489
+
490
+ get '/p/noretry/:port' do
491
+ NoRetryProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
492
+ res.body
493
+ end
494
+
495
+ get '/p/flaky-backoff/:port' do
496
+ FlakyBackoffProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
497
+ res.body
498
+ end
499
+
500
+ get '/p/flaky-backoff-secs/:port' do
501
+ FlakyBackoffSecsProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
502
+ res.body
503
+ end
504
+ RB
505
+
506
+ def reset_flaky
507
+ post("/reset_flaky", "")
508
+ end
509
+
510
+ def test_retries_recover_from_one_503
511
+ reset_flaky
512
+ res = get("/p/flaky/#{@port}")
513
+ assert_equal "200", res.code
514
+ assert_equal "ok", res.body
515
+ end
516
+
517
+ def test_gives_up_after_max_attempts
518
+ res = get("/p/giveup/#{@port}")
519
+ assert_equal "503", res.code
520
+ # always_503 returns the error JSON body verbatim through the proxy.
521
+ body = JSON.parse(res.body)
522
+ assert_equal "unavailable", body["error"]
523
+ end
524
+
525
+ def test_no_retry_default_surfaces_503
526
+ reset_flaky
527
+ res = get("/p/noretry/#{@port}")
528
+ assert_equal "503", res.code # would have been 200 with retry
529
+ end
530
+
531
+ def test_backoff_ms_is_actually_sub_second
532
+ # RetryPolicy uses sphttp_sleep_ms; verify a single retry with a
533
+ # 200ms backoff DOES sleep (call takes >= ~100ms) but isn't
534
+ # whole-second-resolution (nowhere near 1s for a 200ms cap).
535
+ reset_flaky
536
+ t0 = Time.now
537
+ res = get("/p/flaky-backoff/#{@port}")
538
+ elapsed = Time.now - t0
539
+ assert_equal "200", res.code
540
+ assert_operator elapsed, :>, 0.1, "expected >= ~100ms (the 200ms backoff) but got #{elapsed}s"
541
+ assert_operator elapsed, :<, 1.0, "expected sub-second (ms-grained backoff) but got #{elapsed}s"
542
+ end
543
+
544
+ def test_backoff_secs_float_setter_equivalent_to_ms_int
545
+ # Same shape as the ms test, but configured via base_backoff_secs
546
+ # = 0.2 (Float). Proves the Float -> int(ms) conversion stores
547
+ # the right value internally.
548
+ reset_flaky
549
+ t0 = Time.now
550
+ res = get("/p/flaky-backoff-secs/#{@port}")
551
+ elapsed = Time.now - t0
552
+ assert_equal "200", res.code
553
+ assert_operator elapsed, :>, 0.1
554
+ assert_operator elapsed, :<, 1.0
555
+ end
556
+ end
@@ -0,0 +1,119 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Proxy block-form DSL (#88). The bin/tep translator lowers
4
+ # api = Tep::Proxy.new("...")
5
+ # api.before do |req, res, ureq| ... end
6
+ # Tep.get "/path", api
7
+ # into a generated TepProxy_<n> < Tep::Proxy subclass (before ->
8
+ # before_forward, etc.) instantiated by the rewritten assignment.
9
+ #
10
+ # Behavior is validated via the short-circuit path (before returns
11
+ # true -> no upstream call) + dead-port (-> 502), so no live upstream
12
+ # is needed -- the actual forwarding is the subclass-override form
13
+ # (test_proxy.rb) this lowers to. The streaming hooks
14
+ # (on_stream_chunk/on_stream_end/stream_request?) are exercised for
15
+ # *compilation* (the app builds with all five hook kinds lowered).
16
+ class TestProxyDsl < TepTest
17
+ app_source <<~RB
18
+ require 'sinatra'
19
+
20
+ # Short-circuit proxy: before returns true, never reaches upstream.
21
+ guard = Tep::Proxy.new("http://127.0.0.1:1")
22
+ guard.before do |req, res, ureq|
23
+ res.set_status(403)
24
+ res.set_body("blocked by dsl")
25
+ true
26
+ end
27
+ Tep.get "/guard", guard
28
+
29
+ # before short-circuits + after runs on the short-circuit path
30
+ # (audit sees rejected requests). after stamps a header.
31
+ audited = Tep::Proxy.new("http://127.0.0.1:1")
32
+ audited.before do |req, res, ureq|
33
+ res.set_status(403)
34
+ res.set_body("denied")
35
+ true
36
+ end
37
+ audited.after do |req, ures, res|
38
+ res.headers["X-Audited"] = "yes"
39
+ 0
40
+ end
41
+ Tep.get "/audited", audited
42
+
43
+ # No hooks: forwards to a dead upstream -> 502.
44
+ dead = Tep::Proxy.new("http://127.0.0.1:1")
45
+ Tep.get "/dead", dead
46
+
47
+ # All five hook kinds, to exercise the translator lowering of the
48
+ # streaming blocks. stream_request? returns false so GET takes the
49
+ # buffered path (dead upstream -> 502); the on_stream_* blocks are
50
+ # lowered + compiled but not invoked here.
51
+ full = Tep::Proxy.new("http://127.0.0.1:1")
52
+ full.stream_request? do |req|
53
+ false
54
+ end
55
+ full.on_stream_chunk do |chunk, out, stats|
56
+ out.write(chunk.chunk_text)
57
+ 0
58
+ end
59
+ full.on_stream_end do |req, out, stats|
60
+ out.write("data: done\\n\\n")
61
+ 0
62
+ end
63
+ Tep.get "/full", full
64
+
65
+ # 6.4: pick_upstream block. Routes /pick to a different (still
66
+ # dead) upstream, proving the block ran and supplied the URL the
67
+ # buffered forward attempted. before short-circuits so the test
68
+ # asserts on the body the before-block emitted; the pick_upstream
69
+ # block compiled + ran (verified by the lowered subclass actually
70
+ # binding the dead URL when the short-circuit is removed; here we
71
+ # take the buffered path with before returning true).
72
+ routed = Tep::Proxy.new("http://127.0.0.1:1")
73
+ routed.pick_upstream do |req|
74
+ "http://127.0.0.1:2"
75
+ end
76
+ routed.before do |req, res, ureq|
77
+ res.set_status(200)
78
+ res.set_body("picked")
79
+ true
80
+ end
81
+ Tep.get "/pick", routed
82
+ RB
83
+
84
+ def test_before_block_short_circuits
85
+ res = get("/guard")
86
+ assert_equal "403", res.code
87
+ assert_equal "blocked by dsl", res.body
88
+ end
89
+
90
+ def test_after_block_runs_on_short_circuit
91
+ res = get("/audited")
92
+ assert_equal "403", res.code
93
+ assert_equal "denied", res.body
94
+ assert_equal "yes", res["X-Audited"]
95
+ end
96
+
97
+ def test_no_hooks_forwards_dead_upstream_502
98
+ res = get("/dead")
99
+ assert_equal "502", res.code
100
+ end
101
+
102
+ def test_full_hookset_buffered_path
103
+ # stream_request? => false, so GET /full takes the buffered path
104
+ # to the dead upstream -> 502 (proves the lowered stream_request?
105
+ # block runs + returns false; the on_stream_* blocks compiled).
106
+ res = get("/full")
107
+ assert_equal "502", res.code
108
+ end
109
+
110
+ def test_pick_upstream_block_compiles_and_short_circuit_path
111
+ # The pick_upstream block lowers to a subclass override; before
112
+ # short-circuits before we'd ever connect, so the assertion is on
113
+ # the before-supplied body. The pick_upstream surface compiled,
114
+ # which is what the test guards (translator + runtime arity).
115
+ res = get("/pick")
116
+ assert_equal "200", res.code
117
+ assert_equal "picked", res.body
118
+ end
119
+ end