@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
@@ -70,31 +70,32 @@ exposition:
70
70
  status: 404
71
71
  requests: 500
72
72
  interval: 30
73
- cooldown: 30
74
73
  ```
75
74
 
76
- Requests are counted per `key`. Once `requests` are counted within `interval` seconds, further
77
- requests carrying that key are answered `429 Too Many Requests` until `cooldown` seconds pass.
78
- `interval` and `cooldown` are in seconds.
75
+ Requests are metered per `key`. A key may spend `requests` at once, and earns them back at a rate of
76
+ `requests` per `interval` seconds. A request carrying a key with nothing left to spend is answered
77
+ `429 Too Many Requests`, with a `Retry-After` saying how long until one would be admitted again.
78
+ `interval` is in seconds.
79
79
 
80
- `requests` is a budget for the **whole group** of gateway instances, not for one of them. Counting
81
- goes through Redis, so the instances converge on one number rather than each enforcing the limit
82
- separately. The number they act on is a lower bound: it never claims more requests than were really
83
- made, and it can lag by up to one interval. Precise per-request enforcement is not what this is for.
80
+ So `requests` is the burst, and `requests / interval` the rate it is repaid at. There is no window to
81
+ save a budget up in and spend twice across the edge of, and no lockout to sit out: a key that has
82
+ overspent is admitted again as soon as it has earned a single request back, and a client that keeps
83
+ asking meanwhile is not penalised for it.
84
84
 
85
- The lag is worth knowing about when `requests` is small. An instance sends what it has counted once
86
- an interval, and what the group made of it only comes back an interval later so between its send
87
- and that reply, an instance is going on what it alone has counted since. A limit of `1` blocks on
88
- the first request whatever happens; a limit of a handful can be overshot in the first interval of a
89
- burst, before any instance has a group number to act on. Set `requests` for the rate you want to
90
- stop, not for an exact ceiling.
85
+ `requests` is a budget for the **whole group** of gateway instances, not for one of them. Instances
86
+ reconcile through Redis on a timer, so they converge on one number rather than each enforcing the
87
+ limit separately. Between two of those, an instance goes on what it alone has spent so the group
88
+ can overshoot by what the other instances admit within that window, and by no more. The window is a
89
+ tenth of `interval`, between a quarter of a second and two seconds, whatever `interval` is. Precise
90
+ per-request enforcement is not what this is for.
91
91
 
92
- An instance that cannot reach Redis keeps throttling on what it has seen itself, and keeps serving.
92
+ An instance that cannot reach Redis keeps throttling on what it has seen itself and keeps serving,
93
+ and reports what it could not the next time it gets through.
93
94
 
94
95
  ### Key components
95
96
 
96
- What a request is counted against. Give one, or a list — a list keys on the combination, so
97
- `[route, ip]` counts each address separately on each route.
97
+ What a request is metered against. Give one, or a list — a list keys on the combination, so
98
+ `[route, ip]` meters each address separately on each route.
98
99
 
99
100
  - `ip` — the client address, read from `X-Forwarded-For` where it is set and not private,
100
101
  and from the connection otherwise.
@@ -107,11 +108,11 @@ What a request is counted against. Give one, or a list — a list keys on the co
107
108
 
108
109
  ### Conditions
109
110
 
110
- What is counted, as opposed to what it is counted against. A request that fails a condition is
111
- served and not counted. Conditions are evaluated against the response, so they cannot decide whether
112
- to block — only whether to count.
111
+ What is metered, as opposed to what it is metered against. A request that fails a condition is served
112
+ and costs nothing. Conditions are evaluated against the response, so they cannot decide whether to
113
+ refuse a request — only whether it is charged for, once it has been answered.
113
114
 
114
- - `status: <code>` — count only responses with this status.
115
+ - `status: <code>` — charge only for responses with this status.
115
116
 
116
117
  ```yaml
117
118
  io:throttle:
@@ -119,6 +120,8 @@ io:throttle:
119
120
  condition:
120
121
  status: 404
121
122
  requests: 20
122
- interval: 60
123
- cooldown: 600
123
+ interval: 600
124
124
  ```
125
+
126
+ An address may probe for twenty missing paths at once, and thereafter for one every thirty seconds.
127
+ Requests that find something are served and never counted against it.
@@ -1,10 +1,11 @@
1
1
  # Decentralized Request Throttling
2
2
 
3
- > **Superseded.** The design below was never implemented. `io:throttle` now counts through
4
- > [comcount](https://github.com/temich/comcount), by way of the `stash` extension's `count`, which
5
- > solves the same problem the same way per-process buffers, periodically reconciled through Redis,
6
- > never failing the request pathwith the interval number derived from Redis' own clock instead of
7
- > each node's. This note is kept for the problem statement and the forces, which have not changed.
3
+ > **Superseded.** The design below was never implemented. `io:throttle` is now a
4
+ > [GCRA](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) deciding in each process, with
5
+ > its state periodically reconciled through Redis by way of the `stash` extension's `meter`. It
6
+ > answers the same forces the same waydecide locally, reconcile in the background, never fail the
7
+ > request path and the section below records what it does instead. The problem statement and the
8
+ > forces have not changed.
8
9
 
9
10
  ## Problem
10
11
 
@@ -86,3 +87,36 @@ When nodes are added or removed, the algorithm will adapt in the upcoming interv
86
87
  in a span on each node.
87
88
  2. Time desynchronization between nodes should be insignificant for the selected `INTERVAL` (i.e.,
88
89
  `INTERVAL` >> desync). See Extension point 3.
90
+
91
+ ## What was implemented instead
92
+
93
+ Windows were dropped for a GCRA. A key holds one theoretical arrival time: the moment it would be
94
+ back at zero if nothing more arrived. An admitted request pushes it `interval / requests`
95
+ milliseconds further out, time drags it back, and a request is admitted while it stays within
96
+ `interval` of now. So `requests` is what a key may spend at once and `requests / interval` the rate
97
+ it earns back, with no window edge to burst across and no lockout to time out of — which is why
98
+ `cooldown` is gone.
99
+
100
+ Deciding reads nothing but a local map, so the request path stays free of I/O, exactly as force 1
101
+ demands. What the other gateways have spent arrives on a timer, as **debt**: the milliseconds a key
102
+ owes, draining at a millisecond a millisecond. Debt is the right thing to send for two reasons.
103
+
104
+ 1. It is a duration, not a moment, so processes exchanging it need not agree on the time — force 2,
105
+ answered by not needing an answer. The one clock that matters is Redis', read inside the script.
106
+ 2. It is additive in admissions, so a process reports only its own increments and Redis keeps the
107
+ running total. Nothing has to be divided by a number of nodes, which is as well: the gateway
108
+ cannot observe how many of it there are.
109
+
110
+ The consequences worth knowing:
111
+
112
+ - **Reconciling is decoupled from `interval`.** The timer runs at a tenth of `interval`, clamped
113
+ between 250ms and 2s, so a minute-long budget is no staler than a second-long one. The overshoot
114
+ is bounded by what the other instances admit within one tick — the caveat that replaces the old
115
+ note's `MAX_REQUESTS / N` per span.
116
+ - **The cost is one round trip a tick**, not one per key. Every quota in the process flushes into a
117
+ single script call, which matters because a limiter keyed on `ip` watches as many keys as it has
118
+ clients.
119
+ - **Redis keys expire on their own**, after the debt on them plus a grace, so nothing sweeps them.
120
+ - **Failure degrades, as force 3 demands.** A tick that cannot reach Redis leaves its debt to the
121
+ next one rather than losing it; until then the instance throttles on what it alone has seen, and
122
+ keeps serving.
@@ -85,27 +85,77 @@ Feature: Optimistic concurrency control
85
85
  io:output: true
86
86
  GET: enumerate
87
87
  """
88
+ # sorted and bounded, or the collection does not enumerate the same way twice
88
89
  When the following request is received:
89
90
  """
90
- GET /pots/ HTTP/1.1
91
+ GET /pots/?sort=id:asc&limit=2 HTTP/1.1
91
92
  host: nex.toa.io
92
93
  accept: application/yaml
93
94
  """
94
95
  Then the following reply is sent:
95
96
  """
96
97
  200 OK
97
- etag: ${{ etag }}
98
+ etag: "${{ etag }}"
98
99
  """
100
+ # the same representation must be asked for, the tag identifies it
99
101
  When the following request is received:
100
102
  """
101
- GET /pots/ HTTP/1.1
103
+ GET /pots/?sort=id:asc&limit=2 HTTP/1.1
102
104
  host: nex.toa.io
103
- if-none-match: ${{ etag }}
105
+ accept: application/yaml
106
+ if-none-match: "${{ etag }}"
104
107
  """
105
108
  Then the following reply is sent:
106
109
  """
107
110
  304 Not Modified
108
- etag: ${{ etag }}
111
+ etag: "${{ etag }}"
112
+ """
113
+
114
+ Scenario: `etag` of a reply that carries no version
115
+ Given the `echo` is running with the following manifest:
116
+ """yaml
117
+ exposition:
118
+ /:name:
119
+ io:output: true
120
+ GET: compute
121
+ """
122
+ When the following request is received:
123
+ """
124
+ GET /echo/Bob/ HTTP/1.1
125
+ host: nex.toa.io
126
+ accept: text/plain
127
+ """
128
+ Then the following reply is sent:
129
+ """
130
+ 200 OK
131
+ etag: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
132
+
133
+ Hello Bob
134
+ """
135
+ When the following request is received:
136
+ """
137
+ GET /echo/Bob/ HTTP/1.1
138
+ host: nex.toa.io
139
+ accept: text/plain
140
+ if-none-match: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
141
+ """
142
+ Then the following reply is sent:
143
+ """
144
+ 304 Not Modified
145
+ etag: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
146
+ """
147
+ # the tag identifies the representation, which is what `vary: accept` says
148
+ When the following request is received:
149
+ """
150
+ GET /echo/Bob/ HTTP/1.1
151
+ host: nex.toa.io
152
+ accept: application/yaml
153
+ if-none-match: "2d6a12ffc0a952fbd09f8909de4e0e4b20ba2b906cd12ac22bbef4ee5bd9003e"
154
+ """
155
+ Then the following reply is sent:
156
+ """
157
+ 200 OK
158
+ etag: "360c52c694c418b4a793a4815e611206ab17fe04836351a65638a15d1491810b"
109
159
  """
110
160
 
111
161
  Scenario: Weak `etag`
@@ -9,7 +9,6 @@ Feature: Request throttling
9
9
  key: [path]
10
10
  requests: 1
11
11
  interval: 1
12
- cooldown: 1
13
12
  GET:
14
13
  endpoint: hello
15
14
  """
@@ -25,11 +24,13 @@ Feature: Request throttling
25
24
  """
26
25
  GET /echo/beacon/ HTTP/1.1
27
26
  """
27
+ # an emission is a second, which is what the budget takes to earn one back
28
28
  Then the following reply is sent:
29
29
  """
30
30
  429 Too Many Requests
31
+ retry-after: 1
31
32
  """
32
- Then after 1 second
33
+ Then after 2 seconds
33
34
  When the following request is received:
34
35
  """
35
36
  GET /echo/beacon/ HTTP/1.1
@@ -49,7 +50,6 @@ Feature: Request throttling
49
50
  key: [route]
50
51
  requests: 1
51
52
  interval: 5
52
- cooldown: 5
53
53
  GET:
54
54
  dev:stub:
55
55
  hello: true
@@ -84,7 +84,6 @@ Feature: Request throttling
84
84
  - segment: id
85
85
  requests: 1
86
86
  interval: 5
87
- cooldown: 5
88
87
  GET:
89
88
  dev:stub:
90
89
  hello: true
@@ -129,7 +128,6 @@ Feature: Request throttling
129
128
  key: [identity]
130
129
  requests: 1
131
130
  interval: 5
132
- cooldown: 5
133
131
  endpoint: hello
134
132
  """
135
133
  And transient identity alice
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.269",
3
+ "version": "1.0.0-alpha.270",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@simplewebauthn/server": "13.3.3",
21
- "@toa.io/core": "1.0.0-alpha.266",
21
+ "@toa.io/core": "1.0.0-alpha.270",
22
22
  "@toa.io/generic": "1.0.0-alpha.254",
23
- "@toa.io/schemas": "1.0.0-alpha.263",
23
+ "@toa.io/schemas": "1.0.0-alpha.270",
24
24
  "bcryptjs": "3.0.3",
25
25
  "content-type": "2.1.0",
26
26
  "error-value": "0.4.4",
@@ -31,7 +31,7 @@
31
31
  "minimatch": "10.2.6",
32
32
  "msgpackr": "2.1.0",
33
33
  "negotiator": "1.1.0",
34
- "openspan": "1.0.0-alpha.264",
34
+ "openspan": "1.0.0-alpha.270",
35
35
  "paseto": "3.1.4"
36
36
  },
37
37
  "scripts": {
@@ -62,5 +62,5 @@
62
62
  },
63
63
  "testEnvironment": "node"
64
64
  },
65
- "gitHead": "c2340ae684dcd997332075a7df5cbfe7872d1daa"
65
+ "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
66
66
  }
@@ -31,13 +31,12 @@ properties:
31
31
  requests:
32
32
  type: integer
33
33
  minimum: 1
34
- interval: &interval
34
+ interval:
35
35
  description: Seconds
36
36
  type: integer
37
37
  minimum: 1
38
- cooldown: *interval
38
+ additionalProperties: false
39
39
  required:
40
40
  - key
41
41
  - requests
42
42
  - interval
43
- - cooldown
@@ -10,6 +10,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
10
10
  name: 'foo',
11
11
  mandatory: true,
12
12
  create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
13
+ arrange: jest.fn(),
13
14
  preflight: jest.fn(),
14
15
  settle: jest.fn(),
15
16
  dispose: jest.fn()
@@ -18,6 +19,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
18
19
  name: 'qux',
19
20
  mandatory: true,
20
21
  create: jest.fn((_0: any, _1: any, _2: any) => generate() as any),
22
+ arrange: jest.fn(),
21
23
  preflight: jest.fn(),
22
24
  settle: jest.fn(),
23
25
  dispose: jest.fn()
@@ -26,6 +28,7 @@ const families: Array<jest.MockedObjectDeep<DirectiveFamily>> = [
26
28
  name: 'bar',
27
29
  mandatory: false,
28
30
  create: jest.fn((_0: string, _1: any, _2: any) => generate() as any),
31
+ arrange: jest.fn(),
29
32
  preflight: jest.fn(),
30
33
  settle: jest.fn(),
31
34
  dispose: jest.fn()
@@ -7,18 +7,27 @@ import type * as RTD from './RTD'
7
7
  export class Directives implements RTD.Directives {
8
8
  private readonly sets: RTD.DirectiveSet[]
9
9
 
10
+ /** the span of a stage depends only on the set, so it is built once per route */
11
+ private readonly spans: Spans[]
12
+
10
13
  public constructor (sets: RTD.DirectiveSet[]) {
11
14
  this.sets = sets
15
+ this.spans = sets.map((set) => ({
16
+ preflight: options(set, 'preflight'),
17
+ settle: options(set, 'settle')
18
+ }))
12
19
  }
13
20
 
14
21
  public async preflight (context: Context, parameters: RTD.Parameter[]): Promise<Output> {
15
22
  let output = null
16
23
 
17
- for (const set of this.sets) {
24
+ for (let i = 0; i < this.sets.length; i++) {
25
+ const set = this.sets[i]
26
+
18
27
  if (set.family.preflight === undefined)
19
28
  continue
20
29
 
21
- const out = await console.span(options(set, 'preflight'),
30
+ const out = await console.span(this.spans[i].preflight,
22
31
  async () => await set.family.preflight!(set.directives, context, parameters))
23
32
 
24
33
  if (out === null)
@@ -34,10 +43,13 @@ export class Directives implements RTD.Directives {
34
43
  }
35
44
 
36
45
  public async settle (context: Context, response: OutgoingMessage): Promise<void> {
37
- for (const set of this.sets)
46
+ for (let i = 0; i < this.sets.length; i++) {
47
+ const set = this.sets[i]
48
+
38
49
  if (set.family.settle !== undefined)
39
- await console.span(options(set, 'settle'),
50
+ await console.span(this.spans[i].settle,
40
51
  async () => await set.family.settle!(set.directives, context, response))
52
+ }
41
53
  }
42
54
 
43
55
  public dispose (): void {
@@ -106,6 +118,10 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
106
118
  // The rest keep the order they were declared in, the sort being stable.
107
119
  sets.sort((a, b) => this.rank(a.family.name) - this.rank(b.family.name))
108
120
 
121
+ // whatever order a family needs among its own directives is fixed here, not per request
122
+ for (const set of sets)
123
+ set.family.arrange?.(set.directives)
124
+
109
125
  const directives = new Directives(sets)
110
126
 
111
127
  this.instances.push(directives)
@@ -135,6 +151,11 @@ function options (set: RTD.DirectiveSet, stage: 'preflight' | 'settle'): SpanOpt
135
151
  return options
136
152
  }
137
153
 
154
+ interface Spans {
155
+ preflight: SpanOptions
156
+ settle: SpanOptions
157
+ }
158
+
138
159
  export const shortcuts: RTD.syntax.Shortcuts = new Map([
139
160
  ['anonymous', 'auth:anonymous'],
140
161
  ['anyone', 'auth:anyone'],
@@ -1,5 +1,4 @@
1
1
  import { Readable } from 'node:stream'
2
- import { createHash } from 'node:crypto'
3
2
  import { console } from 'openspan'
4
3
  import { Mapping } from './Mapping'
5
4
  import * as http from './HTTP'
@@ -125,16 +124,13 @@ export class Endpoint implements RTD.Endpoint {
125
124
  if (reply instanceof Readable)
126
125
  return false
127
126
 
128
- const hash = `"${createHash('sha256').update(JSON.stringify(reply)).digest('hex')}"`
129
-
130
- if (etag === hash) {
131
- message.status = 304
132
- message.headers.set('etag', etag)
133
-
134
- return true
135
- }
136
-
137
- message.headers.set('etag', hash)
127
+ /*
128
+ * A reply that carries no version is tagged with a hash of its body. The body is
129
+ * serialized anyway when the response is written, so the tag is computed from what
130
+ * is actually sent rather than from a second serialization of the reply — which
131
+ * makes it specific to the negotiated representation, hence `vary: accept`.
132
+ */
133
+ message.etag = true
138
134
 
139
135
  return false
140
136
  }
package/source/Factory.ts CHANGED
@@ -82,7 +82,22 @@ function configureLogs (): void {
82
82
 
83
83
  const tracesEnv = process.env[TRACES_ENV]
84
84
 
85
- traces(tracesEnv === undefined ? {} : decode<TracesOptions>(tracesEnv))
85
+ traces(tracesEnv === undefined ? development() : decode<TracesOptions>(tracesEnv))
86
+ }
87
+
88
+ /**
89
+ * Tracing is off unless it is configured. The console exporter is a local development
90
+ * mechanism, so it is turned on for `toa dev` and for a boot trace the CLI has already
91
+ * asked for (`runtime/boot/src/span.js`), and nowhere else — a deployment that wants
92
+ * traces annotates `telemetry.traces.exporters`.
93
+ *
94
+ * The gateway boots without the telemetry extension, hence the copy of
95
+ * `extensions/telemetry/source/extension.ts`.
96
+ */
97
+ function development (): TracesOptions {
98
+ const local = process.env.TOA_DEV === '1' || process.env.TOA_BOOT_TRACE === '1'
99
+
100
+ return local ? { exporters: { console: {} } } : {}
86
101
  }
87
102
 
88
103
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -25,18 +25,23 @@ export class Context {
25
25
 
26
26
  private consumed = false
27
27
 
28
- public constructor (authority: string, request: IncomingMessage, properties: Properties) {
28
+ // eslint-disable-next-line max-params
29
+ public constructor (authority: string, request: IncomingMessage, properties: Properties,
30
+ url: URL) {
29
31
  this.authority = authority
30
32
  this.request = request
31
33
 
32
34
  this.id = crypto.randomUUID()
33
- this.url = new URL(request.url, `https://${request.headers.host}`)
35
+ // parsed by the server, which had to parse it anyway to know the request is valid
36
+ this.url = url
34
37
  this.timing = new Timing()
35
38
  this.debug = properties.debug
36
39
  this.log(request)
37
40
 
38
- if (this.request.headers.accept !== undefined) {
39
- const match = SUBTYPE.exec(this.request.headers.accept)
41
+ const accept = this.request.headers.accept
42
+
43
+ if (accept !== undefined) {
44
+ const match = SUBTYPE.exec(accept)
40
45
 
41
46
  if (match !== null) {
42
47
  const {
@@ -50,11 +55,10 @@ export class Context {
50
55
  }
51
56
  }
52
57
 
53
- const negotiator = new Negotiator(this.request)
54
- const mediaType = negotiator.mediaType(types)
58
+ const encoder = negotiate(this.request)
55
59
 
56
- if (mediaType !== undefined)
57
- this.encoder = formats[mediaType]
60
+ if (encoder !== undefined)
61
+ this.encoder = encoder
58
62
  }
59
63
 
60
64
  public async buffer (): Promise<Buffer> {
@@ -97,4 +101,31 @@ interface Properties {
97
101
  debug: boolean
98
102
  }
99
103
 
104
+ /**
105
+ * Negotiation parses the header and sorts the candidates, and the value repeats:
106
+ * clients send one of a handful of `accept` strings. Bounded, the header is theirs.
107
+ */
108
+ function negotiate (request: IncomingMessage): Format | undefined {
109
+ const accept = request.headers.accept ?? ''
110
+ const known = NEGOTIATED.get(accept)
111
+
112
+ if (known !== undefined)
113
+ return known === NONE ? undefined : known
114
+
115
+ const mediaType = new Negotiator(request).mediaType(types)
116
+ const encoder = mediaType === undefined ? undefined : formats[mediaType]
117
+
118
+ if (NEGOTIATED.size >= NEGOTIATED_LIMIT)
119
+ NEGOTIATED.clear()
120
+
121
+ NEGOTIATED.set(accept, encoder ?? NONE)
122
+
123
+ return encoder
124
+ }
125
+
126
+ /** distinguishes "negotiated to nothing" from "not negotiated yet" */
127
+ const NONE = Symbol('not acceptable') as unknown as Format
128
+ const NEGOTIATED = new Map<string, Format>()
129
+ const NEGOTIATED_LIMIT = 1024
130
+
100
131
  const SUBTYPE = /^(?<type>\w{1,32})\/(vnd\.toa\.(?<subtype>\S{1,32})\+)(?<suffix>\S{1,32})$/
@@ -92,10 +92,10 @@ export class Server extends Connector {
92
92
  response.destroy()
93
93
  })
94
94
 
95
- const invalid = validate(request)
95
+ const url = parse(request)
96
96
 
97
- if (invalid !== null) {
98
- console.warn('Invalid request', errorAttributes(request, invalid))
97
+ if (url instanceof Error) {
98
+ console.warn('Invalid request', errorAttributes(request, url))
99
99
 
100
100
  response.writeHead(400).end()
101
101
 
@@ -129,8 +129,8 @@ export class Server extends Connector {
129
129
  const remote = trace(request.headers)
130
130
 
131
131
  const processing = remote === null
132
- ? this.serve(request, response, authority)
133
- : run(remote, async () => await this.serve(request, response, authority))
132
+ ? this.serve(request, response, authority, url)
133
+ : run(remote, async () => await this.serve(request, response, authority, url))
134
134
 
135
135
  processing.catch((error) => {
136
136
  console.error('Request processing failed', error)
@@ -140,9 +140,11 @@ export class Server extends Connector {
140
140
  })
141
141
  }
142
142
 
143
+ // eslint-disable-next-line max-params
143
144
  private async serve (request: http.IncomingMessage,
144
145
  response: http.ServerResponse,
145
- authority: string): Promise<void> {
146
+ authority: string,
147
+ url: URL): Promise<void> {
146
148
  await console.span({
147
149
  name: `${request.method} ${request.url}`,
148
150
  kind: 'server',
@@ -151,7 +153,7 @@ export class Server extends Connector {
151
153
  }, async () => {
152
154
  response.setHeader('ray', current()!.traceId)
153
155
 
154
- const context = new Context(authority, request as IncomingMessage, this.properties)
156
+ const context = new Context(authority, request as IncomingMessage, this.properties, url)
155
157
 
156
158
  await this.process!(context)
157
159
  .then(this.success(context, response))
@@ -201,6 +203,10 @@ export class Server extends Connector {
201
203
 
202
204
  const message: OutgoingMessage = { status: response.statusCode }
203
205
 
206
+ // eslint-disable-next-line max-depth
207
+ if (exception instanceof Exception && exception.headers !== undefined)
208
+ message.headers = exception.headers
209
+
204
210
  // eslint-disable-next-line max-depth
205
211
  if (context.encoder === null)
206
212
  message.body = undefined
@@ -226,11 +232,10 @@ export class Server extends Connector {
226
232
  }
227
233
  }
228
234
 
229
- function validate (request: http.IncomingMessage): null | Error {
235
+ /** Parsing the URL is how a request is validated, so the `Context` is handed the result. */
236
+ function parse (request: http.IncomingMessage): URL | Error {
230
237
  try {
231
- void new URL(request.url!, `https://${request.headers.host}`)
232
-
233
- return null
238
+ return new URL(request.url!, `https://${request.headers.host}`)
234
239
  } catch (error) {
235
240
  return error as Error
236
241
  }
@@ -2,10 +2,14 @@ export class Exception extends Error {
2
2
  public readonly status: number
3
3
  public readonly body?: any
4
4
 
5
- protected constructor (status: number, body?: any) {
5
+ /** Headers the status is not complete without, like `Retry-After` on a 429. */
6
+ public readonly headers?: Headers
7
+
8
+ protected constructor (status: number, body?: any, headers?: Headers) {
6
9
  super()
7
10
  this.status = status
8
11
  this.body = body
12
+ this.headers = headers
9
13
  }
10
14
  }
11
15
 
@@ -79,8 +83,10 @@ export class UnprocessableEntity extends ClientError {
79
83
  }
80
84
 
81
85
  export class TooManyRequests extends ClientError {
82
- public constructor () {
83
- super(429)
86
+ /** @param retry Seconds until the request would be admitted. */
87
+ public constructor (retry?: number) {
88
+ super(429, undefined,
89
+ retry === undefined ? undefined : new Headers({ 'retry-after': String(retry) }))
84
90
  }
85
91
  }
86
92