@toa.io/extensions.exposition 1.0.0-alpha.269 → 1.0.0-alpha.270

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 (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/components/exposition.stash/manifest.toa.yaml +13 -4
  3. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  4. package/documentation/io.md +26 -23
  5. package/documentation/notes/throttling.md +39 -5
  6. package/features/etag.feature +55 -5
  7. package/features/io.throttle.feature +3 -5
  8. package/package.json +5 -5
  9. package/schemas/io/throttle.cos.yaml +2 -3
  10. package/source/Directive.test.ts +3 -0
  11. package/source/Directive.ts +25 -4
  12. package/source/Endpoint.ts +7 -11
  13. package/source/Factory.ts +16 -1
  14. package/source/HTTP/Context.ts +39 -8
  15. package/source/HTTP/Server.ts +16 -11
  16. package/source/HTTP/exceptions.ts +9 -3
  17. package/source/HTTP/messages.ts +31 -0
  18. package/source/RTD/Directives.ts +6 -0
  19. package/source/directives/auth/Authorization.ts +29 -3
  20. package/source/directives/io/Directive.ts +5 -4
  21. package/source/directives/io/IO.ts +15 -10
  22. package/source/directives/io/Input.ts +3 -3
  23. package/source/directives/io/Output.ts +12 -4
  24. package/source/directives/io/Throttle.ts +9 -12
  25. package/source/directives/io/lib/throttle/Configuration.test.ts +1 -2
  26. package/source/directives/io/lib/throttle/Configuration.ts +2 -4
  27. package/source/directives/io/lib/throttle/Quotas.test.ts +233 -148
  28. package/source/directives/io/lib/throttle/Quotas.ts +129 -53
  29. package/source/directives/io/lib/throttle/Sync.test.ts +153 -0
  30. package/source/directives/io/lib/throttle/Sync.ts +99 -0
  31. package/source/directives/io/lib/throttle/index.ts +1 -0
  32. package/source/directives/io/schemas.test.ts +7 -1
  33. package/transpiled/Directive.d.ts +2 -0
  34. package/transpiled/Directive.js +16 -4
  35. package/transpiled/Directive.js.map +1 -1
  36. package/transpiled/Endpoint.js +7 -8
  37. package/transpiled/Endpoint.js.map +1 -1
  38. package/transpiled/Factory.js +14 -1
  39. package/transpiled/Factory.js.map +1 -1
  40. package/transpiled/HTTP/Context.d.ts +1 -1
  41. package/transpiled/HTTP/Context.js +30 -8
  42. package/transpiled/HTTP/Context.js.map +1 -1
  43. package/transpiled/HTTP/Server.js +14 -10
  44. package/transpiled/HTTP/Server.js.map +1 -1
  45. package/transpiled/HTTP/exceptions.d.ts +5 -2
  46. package/transpiled/HTTP/exceptions.js +7 -3
  47. package/transpiled/HTTP/exceptions.js.map +1 -1
  48. package/transpiled/HTTP/messages.d.ts +2 -0
  49. package/transpiled/HTTP/messages.js +21 -0
  50. package/transpiled/HTTP/messages.js.map +1 -1
  51. package/transpiled/RTD/Directives.d.ts +5 -0
  52. package/transpiled/directives/auth/Authorization.d.ts +1 -0
  53. package/transpiled/directives/auth/Authorization.js +21 -2
  54. package/transpiled/directives/auth/Authorization.js.map +1 -1
  55. package/transpiled/directives/io/Directive.d.ts +4 -4
  56. package/transpiled/directives/io/IO.d.ts +9 -4
  57. package/transpiled/directives/io/IO.js +14 -9
  58. package/transpiled/directives/io/IO.js.map +1 -1
  59. package/transpiled/directives/io/Input.d.ts +1 -1
  60. package/transpiled/directives/io/Input.js +3 -3
  61. package/transpiled/directives/io/Input.js.map +1 -1
  62. package/transpiled/directives/io/Output.d.ts +2 -0
  63. package/transpiled/directives/io/Output.js +9 -3
  64. package/transpiled/directives/io/Output.js.map +1 -1
  65. package/transpiled/directives/io/Throttle.d.ts +3 -5
  66. package/transpiled/directives/io/Throttle.js +8 -10
  67. package/transpiled/directives/io/Throttle.js.map +1 -1
  68. package/transpiled/directives/io/lib/throttle/Configuration.d.ts +0 -1
  69. package/transpiled/directives/io/lib/throttle/Configuration.js +2 -3
  70. package/transpiled/directives/io/lib/throttle/Configuration.js.map +1 -1
  71. package/transpiled/directives/io/lib/throttle/Quotas.d.ts +46 -22
  72. package/transpiled/directives/io/lib/throttle/Quotas.js +107 -46
  73. package/transpiled/directives/io/lib/throttle/Quotas.js.map +1 -1
  74. package/transpiled/directives/io/lib/throttle/Sync.d.ts +30 -0
  75. package/transpiled/directives/io/lib/throttle/Sync.js +76 -0
  76. package/transpiled/directives/io/lib/throttle/Sync.js.map +1 -0
  77. package/transpiled/directives/io/lib/throttle/index.d.ts +1 -0
  78. package/transpiled/directives/io/lib/throttle/index.js +3 -1
  79. package/transpiled/directives/io/lib/throttle/index.js.map +1 -1
  80. package/transpiled/tsconfig.tsbuildinfo +1 -1
  81. package/components/exposition.stash/operations/count.js +0 -7
@@ -1,6 +1,6 @@
1
1
  import { setTimeout } from 'node:timers/promises'
2
2
  import { Quotas } from './Quotas'
3
- import type { Remote } from '@toa.io/core'
3
+ import type { Batch } from './Sync'
4
4
  import type { Configuration } from './Configuration'
5
5
  import type { Input as Context, Output } from '../../../../io'
6
6
 
@@ -8,52 +8,77 @@ let quotas: Quotas
8
8
  let configuration: Configuration
9
9
  let context: Context
10
10
  let output: Output
11
- let counted: Request[]
12
11
 
13
12
  beforeEach(() => {
14
13
  output = { status: 200 }
15
- counted = []
16
14
  })
17
15
 
18
16
  describe('common', () => {
19
17
  beforeEach(() => {
20
18
  context = createContext()
21
19
  configuration = createConfiguration()
22
- quotas = Quotas.create(configuration, createCounter())
20
+ quotas = Quotas.create(configuration)
23
21
  })
24
22
 
25
- it('should be ok', async () => {
26
- expect(quotas.ok(context, [])).toBe(true)
23
+ it('should admit a burst of the whole budget', () => {
24
+ expect(quotas.check(context, [])).toBe(0)
25
+ expect(quotas.check(context, [])).toBe(0)
26
+ })
27
+
28
+ it('should refuse past the budget', () => {
29
+ quotas.check(context, [])
30
+ quotas.check(context, [])
27
31
 
28
- await quotas.use(context, output)
32
+ expect(quotas.check(context, [])).toBeGreaterThan(0)
29
33
  })
30
34
 
31
- it('should throttle', async () => {
32
- expect(quotas.ok(context, [])).toBe(true)
35
+ it('should not charge what it refuses', async () => {
36
+ quotas.check(context, [])
37
+ quotas.check(context, [])
38
+ quotas.check(context, [])
39
+ quotas.check(context, [])
33
40
 
34
- await quotas.use(context, output)
35
- await quotas.use(context, output)
41
+ // a refused request does not push admission further out, so one emission is
42
+ // all it takes to be admitted again however hard the key is hammered
43
+ await timeout(configuration.interval / configuration.requests)
36
44
 
37
- expect(quotas.ok(context, [])).toBe(false)
45
+ expect(quotas.check(context, [])).toBe(0)
38
46
  })
39
47
 
40
- it('should unblock after cooldown', async () => {
41
- await quotas.use(context, output)
42
- await quotas.use(context, output)
48
+ it('should earn the budget back over the interval', async () => {
49
+ quotas.check(context, [])
50
+ quotas.check(context, [])
43
51
 
44
- expect(quotas.ok(context, [])).toBe(false)
52
+ expect(quotas.check(context, [])).toBeGreaterThan(0)
45
53
 
46
- await timeout(configuration.cooldown)
54
+ await timeout(configuration.interval)
47
55
 
48
- expect(quotas.ok(context, [])).toBe(true)
56
+ expect(quotas.check(context, [])).toBe(0)
57
+ expect(quotas.check(context, [])).toBe(0)
49
58
  })
59
+ })
50
60
 
51
- it('should count one request per key and interval', async () => {
52
- await quotas.use(context, output)
61
+ describe('retry', () => {
62
+ it('should answer the seconds until the request would be admitted', () => {
63
+ // an emission is 5 seconds, which is what the budget takes to earn one back
64
+ quotas = Quotas.create(createConfiguration({ requests: 2, interval: 10_000 }))
65
+ context = createContext()
66
+
67
+ quotas.check(context, [])
68
+ quotas.check(context, [])
53
69
 
54
- expect(counted).toStrictEqual([
55
- { name: expect.any(String), interval: configuration.interval, amount: 1 }
56
- ])
70
+ expect(quotas.check(context, [])).toBe(5)
71
+ })
72
+
73
+ it('should never answer zero when it refuses', () => {
74
+ quotas = Quotas.create(createConfiguration({ requests: 10, interval: 1000 }))
75
+ context = createContext()
76
+
77
+ for (let i = 0; i < 10; i++)
78
+ quotas.check(context, [])
79
+
80
+ // an emission here is a tenth of a second, and `Retry-After` counts in whole ones
81
+ expect(quotas.check(context, [])).toBe(1)
57
82
  })
58
83
  })
59
84
 
@@ -61,167 +86,224 @@ describe('group', () => {
61
86
  beforeEach(() => {
62
87
  context = createContext()
63
88
  configuration = createConfiguration()
89
+ quotas = Quotas.create(configuration)
90
+ })
91
+
92
+ it('should report what it has charged', () => {
93
+ quotas.check(context, [])
94
+
95
+ const batch = flush()
96
+
97
+ expect(batch).toHaveLength(1)
98
+ expect(batch[0].delta).toBe(configuration.interval / configuration.requests)
99
+ expect(batch[0].quotas).toBe(quotas)
100
+ })
101
+
102
+ it('should refuse on what the group has spent, not on what this process has', () => {
103
+ quotas.check(context, [])
104
+
105
+ const batch = flush()
106
+
107
+ // one request here, but the group is already at capacity
108
+ quotas.settled(batch[0], configuration.interval, Date.now())
109
+
110
+ expect(quotas.check(context, [])).toBeGreaterThan(0)
111
+ })
112
+
113
+ it('should not refuse below the capacity', () => {
114
+ quotas.check(context, [])
115
+
116
+ const batch = flush()
117
+
118
+ quotas.settled(batch[0], 0, Date.now())
119
+
120
+ expect(quotas.check(context, [])).toBe(0)
121
+ })
122
+
123
+ it('should clear what was reported, and keep what was charged since', () => {
124
+ quotas.check(context, [])
125
+
126
+ const batch = flush()
127
+
128
+ quotas.check(context, [])
129
+ quotas.settled(batch[0], 0, Date.now())
130
+
131
+ expect(flush()[0].delta).toBe(configuration.interval / configuration.requests)
132
+ })
133
+
134
+ it('should keep what it could not report', () => {
135
+ quotas.check(context, [])
136
+
137
+ const first = flush()
138
+
139
+ // no `settled`, as a tick that cannot reach Redis leaves the debt to the next one
140
+ expect(flush()[0].delta).toBe(first[0].delta)
64
141
  })
65
142
 
66
- it('should block on what the group counted, not on what this process did', async () => {
67
- // one request here, but the group is already at the limit
68
- quotas = Quotas.create(configuration, createCounter(() => configuration.requests))
143
+ it('should take a debt for a key it has already dropped', () => {
144
+ quotas.check(context, [])
69
145
 
70
- await quotas.use(context, output)
146
+ const batch = flush()
71
147
 
72
- expect(quotas.ok(context, [])).toBe(false)
148
+ quotas.settled(batch[0], 0, Date.now())
149
+ flush(Date.now() + configuration.interval)
150
+
151
+ quotas.settled({ ...batch[0], delta: 0 }, configuration.interval, Date.now())
152
+
153
+ expect(quotas.check(context, [])).toBeGreaterThan(0)
73
154
  })
74
155
 
75
- it('should not block below the limit', async () => {
76
- quotas = Quotas.create(configuration, createCounter(() => configuration.requests - 1))
156
+ it('should name keys so that quotas with different budgets never share one', () => {
157
+ const other = Quotas.create(createConfiguration({ requests: 3 }))
158
+
159
+ quotas.check(context, [])
160
+ other.check(context, [])
77
161
 
78
- await quotas.use(context, output)
162
+ const batch = flush(Date.now(), quotas, other)
79
163
 
80
- expect(quotas.ok(context, [])).toBe(true)
164
+ expect(batch[0].key).toBe(batch[1].key)
165
+ expect(quotas.name(batch[0].key)).not.toBe(other.name(batch[1].key))
166
+ })
167
+ })
168
+
169
+ describe('sweeping', () => {
170
+ beforeEach(() => {
171
+ context = createContext()
172
+ configuration = createConfiguration()
173
+ quotas = Quotas.create(configuration)
174
+ })
175
+
176
+ it('should drop a key that is out of debt with nothing to report', () => {
177
+ quotas.check(context, [])
178
+
179
+ const batch = flush()
180
+
181
+ quotas.settled(batch[0], 0, Date.now())
182
+
183
+ // once the debt has drained, a key says no more than an absent one
184
+ expect(flush(Date.now() + configuration.interval)).toHaveLength(0)
185
+ expect(flush(Date.now() + configuration.interval)).toHaveLength(0)
186
+ expect(quotas.check(context, [])).toBe(0)
187
+ })
188
+
189
+ it('should keep a key that still owes something', () => {
190
+ quotas.check(context, [])
191
+
192
+ expect(flush(Date.now() + configuration.interval)).toHaveLength(1)
81
193
  })
82
194
  })
83
195
 
84
196
  describe('path', () => {
85
197
  beforeEach(() => {
86
198
  configuration = createConfiguration()
87
- quotas = Quotas.create(configuration, createCounter())
199
+ quotas = Quotas.create(configuration)
88
200
  })
89
201
 
90
- it('should have separate quotas', async () => {
202
+ it('should have separate quotas', () => {
91
203
  const one = createContext({ url: new URL('http://localhost/one/') })
92
204
  const two = createContext({ url: new URL('http://localhost/two/') })
93
205
 
94
- await quotas.use(one, output)
95
- await quotas.use(one, output)
206
+ quotas.check(one, [])
207
+ quotas.check(one, [])
96
208
 
97
- expect(quotas.ok(one, [])).toBe(false)
98
- expect(quotas.ok(two, [])).toBe(true)
99
-
100
- await quotas.use(two, output)
101
- await quotas.use(two, output)
102
-
103
- expect(quotas.ok(two, [])).toBe(false)
209
+ expect(quotas.check(one, [])).toBeGreaterThan(0)
210
+ expect(quotas.check(two, [])).toBe(0)
104
211
  })
105
212
  })
106
213
 
107
214
  describe('ip', () => {
108
215
  beforeEach(() => {
109
216
  configuration = createConfiguration({ key: [{ method: 'ip' }] })
110
- quotas = Quotas.create(configuration, createCounter())
217
+ quotas = Quotas.create(configuration)
111
218
  })
112
219
 
113
- it('should have separate quotas', async () => {
220
+ it('should have separate quotas', () => {
114
221
  const one = createContext({ request: { headers: { 'x-forwarded-for': '1.1.1.1' } } })
115
222
  const two = createContext({ request: { headers: { 'x-forwarded-for': '2.2.2.2' } } })
116
223
 
117
- await quotas.use(one, output)
118
- await quotas.use(one, output)
119
-
120
- expect(quotas.ok(one, [])).toBe(false)
121
- expect(quotas.ok(two, [])).toBe(true)
224
+ quotas.check(one, [])
225
+ quotas.check(one, [])
122
226
 
123
- await quotas.use(two, output)
124
- await quotas.use(two, output)
125
-
126
- expect(quotas.ok(two, [])).toBe(false)
227
+ expect(quotas.check(one, [])).toBeGreaterThan(0)
228
+ expect(quotas.check(two, [])).toBe(0)
127
229
  })
128
230
  })
129
231
 
130
232
  describe('route', () => {
131
- it('should key on the route, not on the path it came in on', async () => {
132
- quotas = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
133
- createCounter(), '/users/:id')
233
+ it('should key on the route, not on the path it came in on', () => {
234
+ quotas = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }), '/users/:id')
134
235
 
135
236
  const one = createContext({ url: new URL('http://localhost/users/1/') })
136
237
  const two = createContext({ url: new URL('http://localhost/users/2/') })
137
238
 
138
- await quotas.use(one, output)
139
- await quotas.use(two, output)
239
+ quotas.check(one, [])
240
+ quotas.check(two, [])
140
241
 
141
242
  // two paths, one route, one quota — which is the whole difference from `path`
142
- expect(counted[0].name).toBe(counted[1].name)
143
- expect(quotas.ok(one, [])).toBe(false)
243
+ expect(flush()).toHaveLength(1)
244
+ expect(quotas.check(one, [])).toBeGreaterThan(0)
144
245
  })
145
246
 
146
- it('should have separate quotas per route', async () => {
147
- const users = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
148
- createCounter(), '/users/:id')
149
-
150
- const posts = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }),
151
- createCounter(), '/posts/:id')
247
+ it('should have separate quotas per route', () => {
248
+ const users = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }), '/users/:id')
249
+ const posts = Quotas.create(createConfiguration({ key: [{ method: 'route' }] }), '/posts/:id')
152
250
 
153
251
  context = createContext({ url: new URL('http://localhost/users/1/') })
154
252
 
155
- await users.use(context, output)
156
- await posts.use(context, output)
253
+ users.check(context, [])
254
+ posts.check(context, [])
157
255
 
158
- expect(counted[0].name).not.toBe(counted[1].name)
256
+ const batch = flush(Date.now(), users, posts)
257
+
258
+ expect(batch[0].key).not.toBe(batch[1].key)
159
259
  })
160
260
  })
161
261
 
162
262
  describe('segment', () => {
163
263
  beforeEach(() => {
164
264
  configuration = createConfiguration({ key: [{ method: 'segment', options: 'id' }] })
165
- quotas = Quotas.create(configuration, createCounter())
166
- })
167
-
168
- it('should have separate quotas per segment value', async () => {
265
+ quotas = Quotas.create(configuration)
169
266
  context = createContext()
267
+ })
170
268
 
269
+ it('should have separate quotas per segment value', () => {
171
270
  const one = [{ name: 'id', value: '1' }]
172
271
  const two = [{ name: 'id', value: '2' }]
173
272
 
174
- quotas.ok(context, one)
175
- await quotas.use(context, output)
176
- quotas.ok(context, one)
177
- await quotas.use(context, output)
178
-
179
- expect(quotas.ok(context, one)).toBe(false)
180
- expect(quotas.ok(context, two)).toBe(true)
181
- })
182
-
183
- it('should count under the key preflight saw, which settle cannot recompute', async () => {
184
- const one = createContext()
185
- const two = createContext()
186
-
187
- quotas.ok(one, [{ name: 'id', value: '1' }])
188
- await quotas.use(one, output)
189
-
190
- quotas.ok(two, [{ name: 'id', value: '2' }])
191
- await quotas.use(two, output)
273
+ quotas.check(context, one)
274
+ quotas.check(context, one)
192
275
 
193
- // settle is handed no parameters, so recomputing there would hash an empty
194
- // segment both times and the two requests would count as one key
195
- expect(counted[0].name).not.toBe(counted[1].name)
276
+ expect(quotas.check(context, one)).toBeGreaterThan(0)
277
+ expect(quotas.check(context, two)).toBe(0)
196
278
  })
197
279
  })
198
280
 
199
281
  describe('identity', () => {
200
282
  beforeEach(() => {
201
283
  configuration = createConfiguration({ key: [{ method: 'identity' }] })
202
- quotas = Quotas.create(configuration, createCounter())
284
+ quotas = Quotas.create(configuration)
203
285
  })
204
286
 
205
- it('should have separate quotas per identity', async () => {
287
+ it('should have separate quotas per identity', () => {
206
288
  const one = createContext({ identity: { id: 'one' } })
207
289
  const two = createContext({ identity: { id: 'two' } })
208
290
 
209
- await quotas.use(one, output)
210
- await quotas.use(one, output)
291
+ quotas.check(one, [])
292
+ quotas.check(one, [])
211
293
 
212
- expect(quotas.ok(one, [])).toBe(false)
213
- expect(quotas.ok(two, [])).toBe(true)
294
+ expect(quotas.check(one, [])).toBeGreaterThan(0)
295
+ expect(quotas.check(two, [])).toBe(0)
214
296
  })
215
297
 
216
- it('should put every anonymous request in one quota', async () => {
298
+ it('should put every anonymous request in one quota', () => {
217
299
  const one = createContext({ identity: null })
218
300
  const two = createContext()
219
301
 
220
- await quotas.use(one, output)
221
- await quotas.use(two, output)
302
+ quotas.check(one, [])
303
+ quotas.check(two, [])
222
304
 
223
- expect(quotas.ok(one, [])).toBe(false)
224
- expect(quotas.ok(two, [])).toBe(false)
305
+ expect(quotas.check(one, [])).toBeGreaterThan(0)
306
+ expect(quotas.check(two, [])).toBeGreaterThan(0)
225
307
  })
226
308
  })
227
309
 
@@ -229,69 +311,72 @@ describe('status', () => {
229
311
  beforeEach(() => {
230
312
  context = createContext()
231
313
  configuration = createConfiguration({ condition: [{ method: 'status', options: 404 }] })
232
- quotas = Quotas.create(configuration, createCounter())
314
+ quotas = Quotas.create(configuration)
233
315
  })
234
316
 
235
- it('should not throttle on 200', async () => {
236
- await quotas.use(context, output)
317
+ it('should not charge before the response can be matched', () => {
318
+ quotas.check(context, [])
319
+ quotas.check(context, [])
320
+ quotas.check(context, [])
237
321
 
238
- expect(quotas.ok(context, [])).toBe(true)
322
+ // only the response tells whether a request counts, so checking cannot charge
323
+ expect(flush()).toHaveLength(0)
239
324
  })
240
325
 
241
- it('should not count what the condition rejects', async () => {
242
- await quotas.use(context, output)
326
+ it('should not charge what the condition rejects', () => {
327
+ quotas.check(context, [])
328
+ quotas.use(context, output)
243
329
 
244
- expect(counted).toStrictEqual([])
330
+ expect(flush()).toHaveLength(0)
331
+ expect(quotas.check(context, [])).toBe(0)
245
332
  })
246
333
 
247
- it('should throttle on 404', async () => {
248
- await quotas.use(context, { status: 404 })
249
- await quotas.use(context, { status: 404 })
334
+ it('should charge what the condition accepts', () => {
335
+ for (let i = 0; i < 2; i++) {
336
+ quotas.check(context, [])
337
+ quotas.use(context, { status: 404 })
338
+ }
250
339
 
251
- expect(quotas.ok(context, [])).toBe(false)
340
+ expect(quotas.check(context, [])).toBeGreaterThan(0)
252
341
  })
253
- })
254
342
 
255
- function createConfiguration (properties?: Partial<Configuration>): Configuration {
256
- return Object.assign({ key: [{ method: 'path' }], requests: 2, interval: 10, cooldown: 10 },
257
- properties)
258
- }
343
+ it('should charge the key checking saw, which settling cannot recompute', () => {
344
+ const keys = [{ method: 'segment' as const, options: 'id' }]
259
345
 
260
- function createContext (properties?: any): Context {
261
- return Object.assign({ url: new URL('http://localhost/') }, properties) as unknown as Context
262
- }
346
+ quotas = Quotas.create(createConfiguration({ key: keys, condition: configuration.condition }))
263
347
 
264
- /**
265
- * Stands in for `exposition.stash`. Counting the way one participant does is enough
266
- * here: what a counter reports is comcount's business, and this asserts what the
267
- * quotas do with whatever number comes back.
268
- */
269
- function createCounter (reply?: (count: number) => number): Promise<Remote> {
270
- const counts: Record<string, number> = {}
348
+ const one = createContext()
349
+ const two = createContext()
271
350
 
272
- const remote = {
273
- invoke: async (endpoint: string, request: { input: Request }): Promise<number> => {
274
- expect(endpoint).toBe('count')
351
+ quotas.check(one, [{ name: 'id', value: '1' }])
352
+ quotas.use(one, { status: 404 })
275
353
 
276
- const { name, interval, amount } = request.input
354
+ quotas.check(two, [{ name: 'id', value: '2' }])
355
+ quotas.use(two, { status: 404 })
277
356
 
278
- counted.push({ name, interval, amount })
357
+ // settling is handed no parameters, so recomputing there would hash an empty
358
+ // segment both times and the two requests would charge one key
359
+ expect(flush()).toHaveLength(2)
360
+ })
361
+ })
279
362
 
280
- counts[name] = (counts[name] ?? 0) + amount
363
+ function flush (now: number = Date.now(), ...of: Quotas[]): Batch[] {
364
+ const batch: Batch[] = []
281
365
 
282
- return reply === undefined ? counts[name] : reply(counts[name])
283
- }
284
- }
366
+ for (const one of of.length === 0 ? [quotas] : of)
367
+ one.flush(now, batch)
285
368
 
286
- return Promise.resolve(remote as unknown as Remote)
369
+ return batch
287
370
  }
288
371
 
289
- async function timeout (ms: number): Promise<void> {
290
- await setTimeout(ms * 1.2)
372
+ function createConfiguration (properties?: Partial<Configuration>): Configuration {
373
+ return Object.assign({ key: [{ method: 'path' }], requests: 2, interval: 100 }, properties)
374
+ }
375
+
376
+ function createContext (properties?: any): Context {
377
+ return Object.assign({ url: new URL('http://localhost/') }, properties) as unknown as Context
291
378
  }
292
379
 
293
- interface Request {
294
- name: string
295
- interval: number
296
- amount: number
380
+ async function timeout (ms: number): Promise<void> {
381
+ await setTimeout(ms * 1.2)
297
382
  }