UNPKG

15.9 kBPlain TextView Raw
1/*
2 * Copyright 2019 gRPC authors.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 *
16 */
17
18/* This file implements gRFC A2 and the service config spec:
19 * https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md
20 * https://github.com/grpc/grpc/blob/master/doc/service_config.md. Each
21 * function here takes an object with unknown structure and returns its
22 * specific object type if the input has the right structure, and throws an
23 * error otherwise. */
24
25/* The any type is purposely used here. All functions validate their input at
26 * runtime */
27/* eslint-disable @typescript-eslint/no-explicit-any */
28
29import * as os from 'os';
30import { Status } from './constants';
31import { Duration } from './duration';
32import {
33 LoadBalancingConfig,
34 validateLoadBalancingConfig,
35} from './load-balancer';
36
37export interface MethodConfigName {
38 service: string;
39 method?: string;
40}
41
42export interface RetryPolicy {
43 maxAttempts: number;
44 initialBackoff: string;
45 maxBackoff: string;
46 backoffMultiplier: number;
47 retryableStatusCodes: (Status | string)[];
48}
49
50export interface HedgingPolicy {
51 maxAttempts: number;
52 hedgingDelay?: string;
53 nonFatalStatusCodes?: (Status | string)[];
54}
55
56export 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
66export interface RetryThrottling {
67 maxTokens: number;
68 tokenRatio: number;
69}
70
71export interface ServiceConfig {
72 loadBalancingPolicy?: string;
73 loadBalancingConfig: LoadBalancingConfig[];
74 methodConfig: MethodConfig[];
75 retryThrottling?: RetryThrottling;
76}
77
78export interface ServiceConfigCanaryConfig {
79 clientLanguage?: string[];
80 percentage?: number;
81 clientHostname?: string[];
82 serviceConfig: ServiceConfig;
83}
84
85/**
86 * Recognizes a number with up to 9 digits after the decimal point, followed by
87 * an "s", representing a number of seconds.
88 */
89const DURATION_REGEX = /^\d+(\.\d{1,9})?s$/;
90
91/**
92 * Client language name used for determining whether this client matches a
93 * `ServiceConfigCanaryConfig`'s `clientLanguage` list.
94 */
95const CLIENT_LANGUAGE_STRING = 'node';
96
97function 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
114function 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
155function 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
189function 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
259export 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
272export 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 // Validate method name uniqueness
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
323function 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 // Validate that no unexpected fields are present
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
390function 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 /* For each field, we check if it is present, then only discard the
400 * config if the field value does not match the current client */
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 * Find the "grpc_config" record among the TXT records, parse its value as JSON, validate its contents,
436 * and select a service config with selection fields that all match this client. Most of these steps
437 * can fail with an error; the caller must handle any errors thrown this way.
438 * @param txtRecord The TXT record array that is output from a successful call to dns.resolveTxt
439 * @param percentage A number chosen from the range [0, 100) that is used to select which config to use
440 * @return The service configuration to use, given the percentage value, or null if the service config
441 * data has a valid format but none of the options match the current client.
442 */
443export 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 /* Treat the list of strings in this record as a single string and remove
450 * "grpc_config=" from the beginning. The rest should be a JSON string */
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}