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 |
|
33 | export interface MethodConfigName {
|
34 | service?: string;
|
35 | method?: string;
|
36 | }
|
37 |
|
38 | export interface RetryPolicy {
|
39 | maxAttempts: number;
|
40 | initialBackoff: string;
|
41 | maxBackoff: string;
|
42 | backoffMultiplier: number;
|
43 | retryableStatusCodes: (Status | string)[];
|
44 | }
|
45 |
|
46 | export interface HedgingPolicy {
|
47 | maxAttempts: number;
|
48 | hedgingDelay?: string;
|
49 | nonFatalStatusCodes?: (Status | string)[];
|
50 | }
|
51 |
|
52 | export interface MethodConfig {
|
53 | name: MethodConfigName[];
|
54 | waitForReady?: boolean;
|
55 | timeout?: Duration;
|
56 | maxRequestBytes?: number;
|
57 | maxResponseBytes?: number;
|
58 | retryPolicy?: RetryPolicy;
|
59 | hedgingPolicy?: HedgingPolicy;
|
60 | }
|
61 |
|
62 | export interface RetryThrottling {
|
63 | maxTokens: number;
|
64 | tokenRatio: number;
|
65 | }
|
66 |
|
67 | export interface LoadBalancingConfig {
|
68 | [key: string]: object;
|
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 |
|
99 | if ('service' in obj && obj.service !== '') {
|
100 | if (typeof obj.service !== 'string') {
|
101 | throw new Error(
|
102 | `Invalid method config name: invalid service: expected type string, got ${typeof obj.service}`
|
103 | );
|
104 | }
|
105 | if ('method' in obj && obj.method !== '') {
|
106 | if (typeof obj.method !== 'string') {
|
107 | throw new Error(
|
108 | `Invalid method config name: invalid method: expected type string, got ${typeof obj.service}`
|
109 | );
|
110 | }
|
111 | return {
|
112 | service: obj.service,
|
113 | method: obj.method,
|
114 | };
|
115 | } else {
|
116 | return {
|
117 | service: obj.service,
|
118 | };
|
119 | }
|
120 | } else {
|
121 | if ('method' in obj && obj.method !== undefined) {
|
122 | throw new Error(
|
123 | `Invalid method config name: method set with empty or unset service`
|
124 | );
|
125 | }
|
126 | return {};
|
127 | }
|
128 | }
|
129 |
|
130 | function validateRetryPolicy(obj: any): RetryPolicy {
|
131 | if (
|
132 | !('maxAttempts' in obj) ||
|
133 | !Number.isInteger(obj.maxAttempts) ||
|
134 | obj.maxAttempts < 2
|
135 | ) {
|
136 | throw new Error(
|
137 | 'Invalid method config retry policy: maxAttempts must be an integer at least 2'
|
138 | );
|
139 | }
|
140 | if (
|
141 | !('initialBackoff' in obj) ||
|
142 | typeof obj.initialBackoff !== 'string' ||
|
143 | !DURATION_REGEX.test(obj.initialBackoff)
|
144 | ) {
|
145 | throw new Error(
|
146 | 'Invalid method config retry policy: initialBackoff must be a string consisting of a positive integer followed by s'
|
147 | );
|
148 | }
|
149 | if (
|
150 | !('maxBackoff' in obj) ||
|
151 | typeof obj.maxBackoff !== 'string' ||
|
152 | !DURATION_REGEX.test(obj.maxBackoff)
|
153 | ) {
|
154 | throw new Error(
|
155 | 'Invalid method config retry policy: maxBackoff must be a string consisting of a positive integer followed by s'
|
156 | );
|
157 | }
|
158 | if (
|
159 | !('backoffMultiplier' in obj) ||
|
160 | typeof obj.backoffMultiplier !== 'number' ||
|
161 | obj.backoffMultiplier <= 0
|
162 | ) {
|
163 | throw new Error(
|
164 | 'Invalid method config retry policy: backoffMultiplier must be a number greater than 0'
|
165 | );
|
166 | }
|
167 | if (
|
168 | !('retryableStatusCodes' in obj && Array.isArray(obj.retryableStatusCodes))
|
169 | ) {
|
170 | throw new Error(
|
171 | 'Invalid method config retry policy: retryableStatusCodes is required'
|
172 | );
|
173 | }
|
174 | if (obj.retryableStatusCodes.length === 0) {
|
175 | throw new Error(
|
176 | 'Invalid method config retry policy: retryableStatusCodes must be non-empty'
|
177 | );
|
178 | }
|
179 | for (const value of obj.retryableStatusCodes) {
|
180 | if (typeof value === 'number') {
|
181 | if (!Object.values(Status).includes(value)) {
|
182 | throw new Error(
|
183 | 'Invalid method config retry policy: retryableStatusCodes value not in status code range'
|
184 | );
|
185 | }
|
186 | } else if (typeof value === 'string') {
|
187 | if (!Object.values(Status).includes(value.toUpperCase())) {
|
188 | throw new Error(
|
189 | 'Invalid method config retry policy: retryableStatusCodes value not a status code name'
|
190 | );
|
191 | }
|
192 | } else {
|
193 | throw new Error(
|
194 | 'Invalid method config retry policy: retryableStatusCodes value must be a string or number'
|
195 | );
|
196 | }
|
197 | }
|
198 | return {
|
199 | maxAttempts: obj.maxAttempts,
|
200 | initialBackoff: obj.initialBackoff,
|
201 | maxBackoff: obj.maxBackoff,
|
202 | backoffMultiplier: obj.backoffMultiplier,
|
203 | retryableStatusCodes: obj.retryableStatusCodes,
|
204 | };
|
205 | }
|
206 |
|
207 | function validateHedgingPolicy(obj: any): HedgingPolicy {
|
208 | if (
|
209 | !('maxAttempts' in obj) ||
|
210 | !Number.isInteger(obj.maxAttempts) ||
|
211 | obj.maxAttempts < 2
|
212 | ) {
|
213 | throw new Error(
|
214 | 'Invalid method config hedging policy: maxAttempts must be an integer at least 2'
|
215 | );
|
216 | }
|
217 | if (
|
218 | 'hedgingDelay' in obj &&
|
219 | (typeof obj.hedgingDelay !== 'string' ||
|
220 | !DURATION_REGEX.test(obj.hedgingDelay))
|
221 | ) {
|
222 | throw new Error(
|
223 | 'Invalid method config hedging policy: hedgingDelay must be a string consisting of a positive integer followed by s'
|
224 | );
|
225 | }
|
226 | if ('nonFatalStatusCodes' in obj && Array.isArray(obj.nonFatalStatusCodes)) {
|
227 | for (const value of obj.nonFatalStatusCodes) {
|
228 | if (typeof value === 'number') {
|
229 | if (!Object.values(Status).includes(value)) {
|
230 | throw new Error(
|
231 | 'Invlid method config hedging policy: nonFatalStatusCodes value not in status code range'
|
232 | );
|
233 | }
|
234 | } else if (typeof value === 'string') {
|
235 | if (!Object.values(Status).includes(value.toUpperCase())) {
|
236 | throw new Error(
|
237 | 'Invlid method config hedging policy: nonFatalStatusCodes value not a status code name'
|
238 | );
|
239 | }
|
240 | } else {
|
241 | throw new Error(
|
242 | 'Invlid method config hedging policy: nonFatalStatusCodes value must be a string or number'
|
243 | );
|
244 | }
|
245 | }
|
246 | }
|
247 | const result: HedgingPolicy = {
|
248 | maxAttempts: obj.maxAttempts,
|
249 | };
|
250 | if (obj.hedgingDelay) {
|
251 | result.hedgingDelay = obj.hedgingDelay;
|
252 | }
|
253 | if (obj.nonFatalStatusCodes) {
|
254 | result.nonFatalStatusCodes = obj.nonFatalStatusCodes;
|
255 | }
|
256 | return result;
|
257 | }
|
258 |
|
259 | function validateMethodConfig(obj: any): MethodConfig {
|
260 | const result: MethodConfig = {
|
261 | name: [],
|
262 | };
|
263 | if (!('name' in obj) || !Array.isArray(obj.name)) {
|
264 | throw new Error('Invalid method config: invalid name array');
|
265 | }
|
266 | for (const name of obj.name) {
|
267 | result.name.push(validateName(name));
|
268 | }
|
269 | if ('waitForReady' in obj) {
|
270 | if (typeof obj.waitForReady !== 'boolean') {
|
271 | throw new Error('Invalid method config: invalid waitForReady');
|
272 | }
|
273 | result.waitForReady = obj.waitForReady;
|
274 | }
|
275 | if ('timeout' in obj) {
|
276 | if (typeof obj.timeout === 'object') {
|
277 | if (
|
278 | !('seconds' in obj.timeout) ||
|
279 | !(typeof obj.timeout.seconds === 'number')
|
280 | ) {
|
281 | throw new Error('Invalid method config: invalid timeout.seconds');
|
282 | }
|
283 | if (
|
284 | !('nanos' in obj.timeout) ||
|
285 | !(typeof obj.timeout.nanos === 'number')
|
286 | ) {
|
287 | throw new Error('Invalid method config: invalid timeout.nanos');
|
288 | }
|
289 | result.timeout = obj.timeout;
|
290 | } else if (
|
291 | typeof obj.timeout === 'string' &&
|
292 | DURATION_REGEX.test(obj.timeout)
|
293 | ) {
|
294 | const timeoutParts = obj.timeout
|
295 | .substring(0, obj.timeout.length - 1)
|
296 | .split('.');
|
297 | result.timeout = {
|
298 | seconds: timeoutParts[0] | 0,
|
299 | nanos: (timeoutParts[1] ?? 0) | 0,
|
300 | };
|
301 | } else {
|
302 | throw new Error('Invalid method config: invalid timeout');
|
303 | }
|
304 | }
|
305 | if ('maxRequestBytes' in obj) {
|
306 | if (typeof obj.maxRequestBytes !== 'number') {
|
307 | throw new Error('Invalid method config: invalid maxRequestBytes');
|
308 | }
|
309 | result.maxRequestBytes = obj.maxRequestBytes;
|
310 | }
|
311 | if ('maxResponseBytes' in obj) {
|
312 | if (typeof obj.maxResponseBytes !== 'number') {
|
313 | throw new Error('Invalid method config: invalid maxRequestBytes');
|
314 | }
|
315 | result.maxResponseBytes = obj.maxResponseBytes;
|
316 | }
|
317 | if ('retryPolicy' in obj) {
|
318 | if ('hedgingPolicy' in obj) {
|
319 | throw new Error(
|
320 | 'Invalid method config: retryPolicy and hedgingPolicy cannot both be specified'
|
321 | );
|
322 | } else {
|
323 | result.retryPolicy = validateRetryPolicy(obj.retryPolicy);
|
324 | }
|
325 | } else if ('hedgingPolicy' in obj) {
|
326 | result.hedgingPolicy = validateHedgingPolicy(obj.hedgingPolicy);
|
327 | }
|
328 | return result;
|
329 | }
|
330 |
|
331 | export function validateRetryThrottling(obj: any): RetryThrottling {
|
332 | if (
|
333 | !('maxTokens' in obj) ||
|
334 | typeof obj.maxTokens !== 'number' ||
|
335 | obj.maxTokens <= 0 ||
|
336 | obj.maxTokens > 1000
|
337 | ) {
|
338 | throw new Error(
|
339 | 'Invalid retryThrottling: maxTokens must be a number in (0, 1000]'
|
340 | );
|
341 | }
|
342 | if (
|
343 | !('tokenRatio' in obj) ||
|
344 | typeof obj.tokenRatio !== 'number' ||
|
345 | obj.tokenRatio <= 0
|
346 | ) {
|
347 | throw new Error(
|
348 | 'Invalid retryThrottling: tokenRatio must be a number greater than 0'
|
349 | );
|
350 | }
|
351 | return {
|
352 | maxTokens: +(obj.maxTokens as number).toFixed(3),
|
353 | tokenRatio: +(obj.tokenRatio as number).toFixed(3),
|
354 | };
|
355 | }
|
356 |
|
357 | function validateLoadBalancingConfig(obj: any): LoadBalancingConfig {
|
358 | if (!(typeof obj === 'object' && obj !== null)) {
|
359 | throw new Error(
|
360 | `Invalid loadBalancingConfig: unexpected type ${typeof obj}`
|
361 | );
|
362 | }
|
363 | const keys = Object.keys(obj);
|
364 | if (keys.length > 1) {
|
365 | throw new Error(
|
366 | `Invalid loadBalancingConfig: unexpected multiple keys ${keys}`
|
367 | );
|
368 | }
|
369 | if (keys.length === 0) {
|
370 | throw new Error(
|
371 | 'Invalid loadBalancingConfig: load balancing policy name required'
|
372 | );
|
373 | }
|
374 | return {
|
375 | [keys[0]]: obj[keys[0]],
|
376 | };
|
377 | }
|
378 |
|
379 | export function validateServiceConfig(obj: any): ServiceConfig {
|
380 | const result: ServiceConfig = {
|
381 | loadBalancingConfig: [],
|
382 | methodConfig: [],
|
383 | };
|
384 | if ('loadBalancingPolicy' in obj) {
|
385 | if (typeof obj.loadBalancingPolicy === 'string') {
|
386 | result.loadBalancingPolicy = obj.loadBalancingPolicy;
|
387 | } else {
|
388 | throw new Error('Invalid service config: invalid loadBalancingPolicy');
|
389 | }
|
390 | }
|
391 | if ('loadBalancingConfig' in obj) {
|
392 | if (Array.isArray(obj.loadBalancingConfig)) {
|
393 | for (const config of obj.loadBalancingConfig) {
|
394 | result.loadBalancingConfig.push(validateLoadBalancingConfig(config));
|
395 | }
|
396 | } else {
|
397 | throw new Error('Invalid service config: invalid loadBalancingConfig');
|
398 | }
|
399 | }
|
400 | if ('methodConfig' in obj) {
|
401 | if (Array.isArray(obj.methodConfig)) {
|
402 | for (const methodConfig of obj.methodConfig) {
|
403 | result.methodConfig.push(validateMethodConfig(methodConfig));
|
404 | }
|
405 | }
|
406 | }
|
407 | if ('retryThrottling' in obj) {
|
408 | result.retryThrottling = validateRetryThrottling(obj.retryThrottling);
|
409 | }
|
410 |
|
411 | const seenMethodNames: MethodConfigName[] = [];
|
412 | for (const methodConfig of result.methodConfig) {
|
413 | for (const name of methodConfig.name) {
|
414 | for (const seenName of seenMethodNames) {
|
415 | if (
|
416 | name.service === seenName.service &&
|
417 | name.method === seenName.method
|
418 | ) {
|
419 | throw new Error(
|
420 | `Invalid service config: duplicate name ${name.service}/${name.method}`
|
421 | );
|
422 | }
|
423 | }
|
424 | seenMethodNames.push(name);
|
425 | }
|
426 | }
|
427 | return result;
|
428 | }
|
429 |
|
430 | function validateCanaryConfig(obj: any): ServiceConfigCanaryConfig {
|
431 | if (!('serviceConfig' in obj)) {
|
432 | throw new Error('Invalid service config choice: missing service config');
|
433 | }
|
434 | const result: ServiceConfigCanaryConfig = {
|
435 | serviceConfig: validateServiceConfig(obj.serviceConfig),
|
436 | };
|
437 | if ('clientLanguage' in obj) {
|
438 | if (Array.isArray(obj.clientLanguage)) {
|
439 | result.clientLanguage = [];
|
440 | for (const lang of obj.clientLanguage) {
|
441 | if (typeof lang === 'string') {
|
442 | result.clientLanguage.push(lang);
|
443 | } else {
|
444 | throw new Error(
|
445 | 'Invalid service config choice: invalid clientLanguage'
|
446 | );
|
447 | }
|
448 | }
|
449 | } else {
|
450 | throw new Error('Invalid service config choice: invalid clientLanguage');
|
451 | }
|
452 | }
|
453 | if ('clientHostname' in obj) {
|
454 | if (Array.isArray(obj.clientHostname)) {
|
455 | result.clientHostname = [];
|
456 | for (const lang of obj.clientHostname) {
|
457 | if (typeof lang === 'string') {
|
458 | result.clientHostname.push(lang);
|
459 | } else {
|
460 | throw new Error(
|
461 | 'Invalid service config choice: invalid clientHostname'
|
462 | );
|
463 | }
|
464 | }
|
465 | } else {
|
466 | throw new Error('Invalid service config choice: invalid clientHostname');
|
467 | }
|
468 | }
|
469 | if ('percentage' in obj) {
|
470 | if (
|
471 | typeof obj.percentage === 'number' &&
|
472 | 0 <= obj.percentage &&
|
473 | obj.percentage <= 100
|
474 | ) {
|
475 | result.percentage = obj.percentage;
|
476 | } else {
|
477 | throw new Error('Invalid service config choice: invalid percentage');
|
478 | }
|
479 | }
|
480 |
|
481 | const allowedFields = [
|
482 | 'clientLanguage',
|
483 | 'percentage',
|
484 | 'clientHostname',
|
485 | 'serviceConfig',
|
486 | ];
|
487 | for (const field in obj) {
|
488 | if (!allowedFields.includes(field)) {
|
489 | throw new Error(
|
490 | `Invalid service config choice: unexpected field ${field}`
|
491 | );
|
492 | }
|
493 | }
|
494 | return result;
|
495 | }
|
496 |
|
497 | function validateAndSelectCanaryConfig(
|
498 | obj: any,
|
499 | percentage: number
|
500 | ): ServiceConfig {
|
501 | if (!Array.isArray(obj)) {
|
502 | throw new Error('Invalid service config list');
|
503 | }
|
504 | for (const config of obj) {
|
505 | const validatedConfig = validateCanaryConfig(config);
|
506 | |
507 |
|
508 | if (
|
509 | typeof validatedConfig.percentage === 'number' &&
|
510 | percentage > validatedConfig.percentage
|
511 | ) {
|
512 | continue;
|
513 | }
|
514 | if (Array.isArray(validatedConfig.clientHostname)) {
|
515 | let hostnameMatched = false;
|
516 | for (const hostname of validatedConfig.clientHostname) {
|
517 | if (hostname === os.hostname()) {
|
518 | hostnameMatched = true;
|
519 | }
|
520 | }
|
521 | if (!hostnameMatched) {
|
522 | continue;
|
523 | }
|
524 | }
|
525 | if (Array.isArray(validatedConfig.clientLanguage)) {
|
526 | let languageMatched = false;
|
527 | for (const language of validatedConfig.clientLanguage) {
|
528 | if (language === CLIENT_LANGUAGE_STRING) {
|
529 | languageMatched = true;
|
530 | }
|
531 | }
|
532 | if (!languageMatched) {
|
533 | continue;
|
534 | }
|
535 | }
|
536 | return validatedConfig.serviceConfig;
|
537 | }
|
538 | throw new Error('No matching service config found');
|
539 | }
|
540 |
|
541 |
|
542 |
|
543 |
|
544 |
|
545 |
|
546 |
|
547 |
|
548 |
|
549 |
|
550 | export function extractAndSelectServiceConfig(
|
551 | txtRecord: string[][],
|
552 | percentage: number
|
553 | ): ServiceConfig | null {
|
554 | for (const record of txtRecord) {
|
555 | if (record.length > 0 && record[0].startsWith('grpc_config=')) {
|
556 | |
557 |
|
558 | const recordString = record.join('').substring('grpc_config='.length);
|
559 | const recordJson: any = JSON.parse(recordString);
|
560 | return validateAndSelectCanaryConfig(recordJson, percentage);
|
561 | }
|
562 | }
|
563 | return null;
|
564 | }
|