1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | import * as os from 'os';
|
30 | import { Status } from './constants';
|
31 | import { Duration } from './duration';
|
32 | import {
|
33 | LoadBalancingConfig,
|
34 | validateLoadBalancingConfig,
|
35 | } from './load-balancer';
|
36 |
|
37 | export interface MethodConfigName {
|
38 | service: string;
|
39 | method?: string;
|
40 | }
|
41 |
|
42 | export interface RetryPolicy {
|
43 | maxAttempts: number;
|
44 | initialBackoff: string;
|
45 | maxBackoff: string;
|
46 | backoffMultiplier: number;
|
47 | retryableStatusCodes: (Status | string)[];
|
48 | }
|
49 |
|
50 | export interface HedgingPolicy {
|
51 | maxAttempts: number;
|
52 | hedgingDelay?: string;
|
53 | nonFatalStatusCodes?: (Status | string)[];
|
54 | }
|
55 |
|
56 | export interface MethodConfig {
|
57 | name: MethodConfigName[];
|
58 | waitForReady?: boolean;
|
59 | timeout?: Duration;
|
60 | maxRequestBytes?: number;
|
61 | maxResponseBytes?: number;
|
62 | retryPolicy?: RetryPolicy;
|
63 | hedgingPolicy?: HedgingPolicy;
|
64 | }
|
65 |
|
66 | export interface RetryThrottling {
|
67 | maxTokens: number;
|
68 | tokenRatio: number;
|
69 | }
|
70 |
|
71 | export interface ServiceConfig {
|
72 | loadBalancingPolicy?: string;
|
73 | loadBalancingConfig: LoadBalancingConfig[];
|
74 | methodConfig: MethodConfig[];
|
75 | retryThrottling?: RetryThrottling;
|
76 | }
|
77 |
|
78 | export interface ServiceConfigCanaryConfig {
|
79 | clientLanguage?: string[];
|
80 | percentage?: number;
|
81 | clientHostname?: string[];
|
82 | serviceConfig: ServiceConfig;
|
83 | }
|
84 |
|
85 |
|
86 |
|
87 |
|
88 |
|
89 | const DURATION_REGEX = /^\d+(\.\d{1,9})?s$/;
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 | const CLIENT_LANGUAGE_STRING = 'node';
|
96 |
|
97 | function validateName(obj: any): MethodConfigName {
|
98 | if (!('service' in obj) || typeof obj.service !== 'string') {
|
99 | throw new Error('Invalid method config name: invalid service');
|
100 | }
|
101 | const result: MethodConfigName = {
|
102 | service: obj.service,
|
103 | };
|
104 | if ('method' in obj) {
|
105 | if (typeof obj.method === 'string') {
|
106 | result.method = obj.method;
|
107 | } else {
|
108 | throw new Error('Invalid method config name: invalid method');
|
109 | }
|
110 | }
|
111 | return result;
|
112 | }
|
113 |
|
114 | function validateRetryPolicy(obj: any): RetryPolicy {
|
115 | if (!('maxAttempts' in obj) || !Number.isInteger(obj.maxAttempts) || obj.maxAttempts < 2) {
|
116 | throw new Error('Invalid method config retry policy: maxAttempts must be an integer at least 2');
|
117 | }
|
118 | if (!('initialBackoff' in obj) || typeof obj.initialBackoff !== 'string' || !DURATION_REGEX.test(obj.initialBackoff)) {
|
119 | throw new Error('Invalid method config retry policy: initialBackoff must be a string consisting of a positive integer followed by s');
|
120 | }
|
121 | if (!('maxBackoff' in obj) || typeof obj.maxBackoff !== 'string' || !DURATION_REGEX.test(obj.maxBackoff)) {
|
122 | throw new Error('Invalid method config retry policy: maxBackoff must be a string consisting of a positive integer followed by s');
|
123 | }
|
124 | if (!('backoffMultiplier' in obj) || typeof obj.backoffMultiplier !== 'number' || obj.backoffMultiplier <= 0) {
|
125 | throw new Error('Invalid method config retry policy: backoffMultiplier must be a number greater than 0');
|
126 | }
|
127 | if (!(('retryableStatusCodes' in obj) && Array.isArray(obj.retryableStatusCodes))) {
|
128 | throw new Error('Invalid method config retry policy: retryableStatusCodes is required');
|
129 | }
|
130 | if (obj.retryableStatusCodes.length === 0) {
|
131 | throw new Error('Invalid method config retry policy: retryableStatusCodes must be non-empty');
|
132 | }
|
133 | for (const value of obj.retryableStatusCodes) {
|
134 | if (typeof value === 'number') {
|
135 | if (!Object.values(Status).includes(value)) {
|
136 | throw new Error('Invalid method config retry policy: retryableStatusCodes value not in status code range');
|
137 | }
|
138 | } else if (typeof value === 'string') {
|
139 | if (!Object.values(Status).includes(value.toUpperCase())) {
|
140 | throw new Error('Invalid method config retry policy: retryableStatusCodes value not a status code name');
|
141 | }
|
142 | } else {
|
143 | throw new Error('Invalid method config retry policy: retryableStatusCodes value must be a string or number');
|
144 | }
|
145 | }
|
146 | return {
|
147 | maxAttempts: obj.maxAttempts,
|
148 | initialBackoff: obj.initialBackoff,
|
149 | maxBackoff: obj.maxBackoff,
|
150 | backoffMultiplier: obj.backoffMultiplier,
|
151 | retryableStatusCodes: obj.retryableStatusCodes
|
152 | };
|
153 | }
|
154 |
|
155 | function validateHedgingPolicy(obj: any): HedgingPolicy {
|
156 | if (!('maxAttempts' in obj) || !Number.isInteger(obj.maxAttempts) || obj.maxAttempts < 2) {
|
157 | throw new Error('Invalid method config hedging policy: maxAttempts must be an integer at least 2');
|
158 | }
|
159 | if (('hedgingDelay' in obj) && (typeof obj.hedgingDelay !== 'string' || !DURATION_REGEX.test(obj.hedgingDelay))) {
|
160 | throw new Error('Invalid method config hedging policy: hedgingDelay must be a string consisting of a positive integer followed by s');
|
161 | }
|
162 | if (('nonFatalStatusCodes' in obj) && Array.isArray(obj.nonFatalStatusCodes)) {
|
163 | for (const value of obj.nonFatalStatusCodes) {
|
164 | if (typeof value === 'number') {
|
165 | if (!Object.values(Status).includes(value)) {
|
166 | throw new Error('Invlid method config hedging policy: nonFatalStatusCodes value not in status code range');
|
167 | }
|
168 | } else if (typeof value === 'string') {
|
169 | if (!Object.values(Status).includes(value.toUpperCase())) {
|
170 | throw new Error('Invlid method config hedging policy: nonFatalStatusCodes value not a status code name');
|
171 | }
|
172 | } else {
|
173 | throw new Error('Invlid method config hedging policy: nonFatalStatusCodes value must be a string or number');
|
174 | }
|
175 | }
|
176 | }
|
177 | const result: HedgingPolicy = {
|
178 | maxAttempts: obj.maxAttempts
|
179 | }
|
180 | if (obj.hedgingDelay) {
|
181 | result.hedgingDelay = obj.hedgingDelay;
|
182 | }
|
183 | if (obj.nonFatalStatusCodes) {
|
184 | result.nonFatalStatusCodes = obj.nonFatalStatusCodes;
|
185 | }
|
186 | return result;
|
187 | }
|
188 |
|
189 | function validateMethodConfig(obj: any): MethodConfig {
|
190 | const result: MethodConfig = {
|
191 | name: [],
|
192 | };
|
193 | if (!('name' in obj) || !Array.isArray(obj.name)) {
|
194 | throw new Error('Invalid method config: invalid name array');
|
195 | }
|
196 | for (const name of obj.name) {
|
197 | result.name.push(validateName(name));
|
198 | }
|
199 | if ('waitForReady' in obj) {
|
200 | if (typeof obj.waitForReady !== 'boolean') {
|
201 | throw new Error('Invalid method config: invalid waitForReady');
|
202 | }
|
203 | result.waitForReady = obj.waitForReady;
|
204 | }
|
205 | if ('timeout' in obj) {
|
206 | if (typeof obj.timeout === 'object') {
|
207 | if (
|
208 | !('seconds' in obj.timeout) ||
|
209 | !(typeof obj.timeout.seconds === 'number')
|
210 | ) {
|
211 | throw new Error('Invalid method config: invalid timeout.seconds');
|
212 | }
|
213 | if (
|
214 | !('nanos' in obj.timeout) ||
|
215 | !(typeof obj.timeout.nanos === 'number')
|
216 | ) {
|
217 | throw new Error('Invalid method config: invalid timeout.nanos');
|
218 | }
|
219 | result.timeout = obj.timeout;
|
220 | } else if (
|
221 | typeof obj.timeout === 'string' &&
|
222 | DURATION_REGEX.test(obj.timeout)
|
223 | ) {
|
224 | const timeoutParts = obj.timeout
|
225 | .substring(0, obj.timeout.length - 1)
|
226 | .split('.');
|
227 | result.timeout = {
|
228 | seconds: timeoutParts[0] | 0,
|
229 | nanos: (timeoutParts[1] ?? 0) | 0,
|
230 | };
|
231 | } else {
|
232 | throw new Error('Invalid method config: invalid timeout');
|
233 | }
|
234 | }
|
235 | if ('maxRequestBytes' in obj) {
|
236 | if (typeof obj.maxRequestBytes !== 'number') {
|
237 | throw new Error('Invalid method config: invalid maxRequestBytes');
|
238 | }
|
239 | result.maxRequestBytes = obj.maxRequestBytes;
|
240 | }
|
241 | if ('maxResponseBytes' in obj) {
|
242 | if (typeof obj.maxResponseBytes !== 'number') {
|
243 | throw new Error('Invalid method config: invalid maxRequestBytes');
|
244 | }
|
245 | result.maxResponseBytes = obj.maxResponseBytes;
|
246 | }
|
247 | if ('retryPolicy' in obj) {
|
248 | if ('hedgingPolicy' in obj) {
|
249 | throw new Error('Invalid method config: retryPolicy and hedgingPolicy cannot both be specified');
|
250 | } else {
|
251 | result.retryPolicy = validateRetryPolicy(obj.retryPolicy);
|
252 | }
|
253 | } else if ('hedgingPolicy' in obj) {
|
254 | result.hedgingPolicy = validateHedgingPolicy(obj.hedgingPolicy);
|
255 | }
|
256 | return result;
|
257 | }
|
258 |
|
259 | export function validateRetryThrottling(obj: any): RetryThrottling {
|
260 | if (!('maxTokens' in obj) || typeof obj.maxTokens !== 'number' || obj.maxTokens <=0 || obj.maxTokens > 1000) {
|
261 | throw new Error('Invalid retryThrottling: maxTokens must be a number in (0, 1000]');
|
262 | }
|
263 | if (!('tokenRatio' in obj) || typeof obj.tokenRatio !== 'number' || obj.tokenRatio <= 0) {
|
264 | throw new Error('Invalid retryThrottling: tokenRatio must be a number greater than 0');
|
265 | }
|
266 | return {
|
267 | maxTokens: +(obj.maxTokens as number).toFixed(3),
|
268 | tokenRatio: +(obj.tokenRatio as number).toFixed(3)
|
269 | };
|
270 | }
|
271 |
|
272 | export function validateServiceConfig(obj: any): ServiceConfig {
|
273 | const result: ServiceConfig = {
|
274 | loadBalancingConfig: [],
|
275 | methodConfig: [],
|
276 | };
|
277 | if ('loadBalancingPolicy' in obj) {
|
278 | if (typeof obj.loadBalancingPolicy === 'string') {
|
279 | result.loadBalancingPolicy = obj.loadBalancingPolicy;
|
280 | } else {
|
281 | throw new Error('Invalid service config: invalid loadBalancingPolicy');
|
282 | }
|
283 | }
|
284 | if ('loadBalancingConfig' in obj) {
|
285 | if (Array.isArray(obj.loadBalancingConfig)) {
|
286 | for (const config of obj.loadBalancingConfig) {
|
287 | result.loadBalancingConfig.push(validateLoadBalancingConfig(config));
|
288 | }
|
289 | } else {
|
290 | throw new Error('Invalid service config: invalid loadBalancingConfig');
|
291 | }
|
292 | }
|
293 | if ('methodConfig' in obj) {
|
294 | if (Array.isArray(obj.methodConfig)) {
|
295 | for (const methodConfig of obj.methodConfig) {
|
296 | result.methodConfig.push(validateMethodConfig(methodConfig));
|
297 | }
|
298 | }
|
299 | }
|
300 | if ('retryThrottling' in obj) {
|
301 | result.retryThrottling = validateRetryThrottling(obj.retryThrottling);
|
302 | }
|
303 |
|
304 | const seenMethodNames: MethodConfigName[] = [];
|
305 | for (const methodConfig of result.methodConfig) {
|
306 | for (const name of methodConfig.name) {
|
307 | for (const seenName of seenMethodNames) {
|
308 | if (
|
309 | name.service === seenName.service &&
|
310 | name.method === seenName.method
|
311 | ) {
|
312 | throw new Error(
|
313 | `Invalid service config: duplicate name ${name.service}/${name.method}`
|
314 | );
|
315 | }
|
316 | }
|
317 | seenMethodNames.push(name);
|
318 | }
|
319 | }
|
320 | return result;
|
321 | }
|
322 |
|
323 | function validateCanaryConfig(obj: any): ServiceConfigCanaryConfig {
|
324 | if (!('serviceConfig' in obj)) {
|
325 | throw new Error('Invalid service config choice: missing service config');
|
326 | }
|
327 | const result: ServiceConfigCanaryConfig = {
|
328 | serviceConfig: validateServiceConfig(obj.serviceConfig),
|
329 | };
|
330 | if ('clientLanguage' in obj) {
|
331 | if (Array.isArray(obj.clientLanguage)) {
|
332 | result.clientLanguage = [];
|
333 | for (const lang of obj.clientLanguage) {
|
334 | if (typeof lang === 'string') {
|
335 | result.clientLanguage.push(lang);
|
336 | } else {
|
337 | throw new Error(
|
338 | 'Invalid service config choice: invalid clientLanguage'
|
339 | );
|
340 | }
|
341 | }
|
342 | } else {
|
343 | throw new Error('Invalid service config choice: invalid clientLanguage');
|
344 | }
|
345 | }
|
346 | if ('clientHostname' in obj) {
|
347 | if (Array.isArray(obj.clientHostname)) {
|
348 | result.clientHostname = [];
|
349 | for (const lang of obj.clientHostname) {
|
350 | if (typeof lang === 'string') {
|
351 | result.clientHostname.push(lang);
|
352 | } else {
|
353 | throw new Error(
|
354 | 'Invalid service config choice: invalid clientHostname'
|
355 | );
|
356 | }
|
357 | }
|
358 | } else {
|
359 | throw new Error('Invalid service config choice: invalid clientHostname');
|
360 | }
|
361 | }
|
362 | if ('percentage' in obj) {
|
363 | if (
|
364 | typeof obj.percentage === 'number' &&
|
365 | 0 <= obj.percentage &&
|
366 | obj.percentage <= 100
|
367 | ) {
|
368 | result.percentage = obj.percentage;
|
369 | } else {
|
370 | throw new Error('Invalid service config choice: invalid percentage');
|
371 | }
|
372 | }
|
373 |
|
374 | const allowedFields = [
|
375 | 'clientLanguage',
|
376 | 'percentage',
|
377 | 'clientHostname',
|
378 | 'serviceConfig',
|
379 | ];
|
380 | for (const field in obj) {
|
381 | if (!allowedFields.includes(field)) {
|
382 | throw new Error(
|
383 | `Invalid service config choice: unexpected field ${field}`
|
384 | );
|
385 | }
|
386 | }
|
387 | return result;
|
388 | }
|
389 |
|
390 | function validateAndSelectCanaryConfig(
|
391 | obj: any,
|
392 | percentage: number
|
393 | ): ServiceConfig {
|
394 | if (!Array.isArray(obj)) {
|
395 | throw new Error('Invalid service config list');
|
396 | }
|
397 | for (const config of obj) {
|
398 | const validatedConfig = validateCanaryConfig(config);
|
399 | |
400 |
|
401 | if (
|
402 | typeof validatedConfig.percentage === 'number' &&
|
403 | percentage > validatedConfig.percentage
|
404 | ) {
|
405 | continue;
|
406 | }
|
407 | if (Array.isArray(validatedConfig.clientHostname)) {
|
408 | let hostnameMatched = false;
|
409 | for (const hostname of validatedConfig.clientHostname) {
|
410 | if (hostname === os.hostname()) {
|
411 | hostnameMatched = true;
|
412 | }
|
413 | }
|
414 | if (!hostnameMatched) {
|
415 | continue;
|
416 | }
|
417 | }
|
418 | if (Array.isArray(validatedConfig.clientLanguage)) {
|
419 | let languageMatched = false;
|
420 | for (const language of validatedConfig.clientLanguage) {
|
421 | if (language === CLIENT_LANGUAGE_STRING) {
|
422 | languageMatched = true;
|
423 | }
|
424 | }
|
425 | if (!languageMatched) {
|
426 | continue;
|
427 | }
|
428 | }
|
429 | return validatedConfig.serviceConfig;
|
430 | }
|
431 | throw new Error('No matching service config found');
|
432 | }
|
433 |
|
434 |
|
435 |
|
436 |
|
437 |
|
438 |
|
439 |
|
440 |
|
441 |
|
442 |
|
443 | export function extractAndSelectServiceConfig(
|
444 | txtRecord: string[][],
|
445 | percentage: number
|
446 | ): ServiceConfig | null {
|
447 | for (const record of txtRecord) {
|
448 | if (record.length > 0 && record[0].startsWith('grpc_config=')) {
|
449 | |
450 |
|
451 | const recordString = record.join('').substring('grpc_config='.length);
|
452 | const recordJson: any = JSON.parse(recordString);
|
453 | return validateAndSelectCanaryConfig(recordJson, percentage);
|
454 | }
|
455 | }
|
456 | return null;
|
457 | }
|