UNPKG

7.41 kBJavaScriptView Raw
1// Copyright © 2017, 2021 IBM Corp. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14'use strict';
15
16const pkg = require('../package.json');
17const http = require('http');
18const https = require('https');
19const stream = require('stream');
20const { CloudantV1, CouchdbSessionAuthenticator } = require('@ibm-cloud/cloudant');
21const { IamAuthenticator, NoAuthAuthenticator } = require('ibm-cloud-sdk-core');
22const retryPlugin = require('retry-axios');
23
24const userAgent = 'couchbackup-cloudant/' + pkg.version + ' (Node.js ' +
25 process.version + ')';
26
27// Class for streaming _changes error responses into
28// In general the response is a small error/reason JSON object
29// so it is OK to have this in memory.
30class ResponseWriteable extends stream.Writable {
31 constructor(options) {
32 super(options);
33 this.data = [];
34 }
35
36 _write(chunk, encoding, callback) {
37 this.data.push(chunk);
38 callback();
39 }
40
41 stringBody() {
42 return Buffer.concat(this.data).toString();
43 }
44}
45
46// An interceptor function to help augment error bodies with a little
47// extra information so we can continue to use consistent messaging
48// after the ugprade to @ibm-cloud/cloudant
49const errorHelper = async function(err) {
50 let method;
51 let requestUrl;
52 if (err.response) {
53 if (err.response.config.url) {
54 requestUrl = err.response.config.url;
55 method = err.response.config.method;
56 }
57 // Override the status text with an improved message
58 let errorMsg = `${err.response.status} ${err.response.statusText || ''}: ` +
59 `${method} ${requestUrl}`;
60 if (err.response.data) {
61 // Check if we have a JSON response and try to get the error/reason
62 if (err.response.headers['content-type'] === 'application/json') {
63 if (!err.response.data.error && err.response.data.pipe) {
64 // If we didn't find a JSON object with `error` then we might have a stream response.
65 // Detect the stream by the presence of `pipe` and use it to get the body and parse
66 // the error information.
67 const p = new Promise((resolve, reject) => {
68 const errorBody = new ResponseWriteable();
69 err.response.data.pipe(errorBody)
70 .on('finish', () => { resolve(JSON.parse(errorBody.stringBody())); })
71 .on('error', () => { reject(err); });
72 });
73 // Replace the stream on the response with the parsed object
74 err.response.data = await p;
75 }
76 // Append the error/reason if available
77 if (err.response.data.error) {
78 // Override the status text with our more complete message
79 errorMsg += ` - Error: ${err.response.data.error}`;
80 if (err.response.data.reason) {
81 errorMsg += `, Reason: ${err.response.data.reason}`;
82 }
83 }
84 } else {
85 errorMsg += err.response.data;
86 }
87 // Set a new message for use by the node-sdk-core
88 // We use the errors array because it gets processed
89 // ahead of all other service errors.
90 err.response.data.errors = [{ message: errorMsg }];
91 }
92 } else if (err.request) {
93 if (!err.message.includes(err.config.url)) {
94 // Augment the message with the URL and method
95 // but don't do it again if we already have the URL.
96 err.message = `${err.message}: ${err.config.method} ${err.config.url}`;
97 }
98 }
99 return Promise.reject(err);
100};
101
102module.exports = {
103 client: function(rawUrl, opts) {
104 const url = new URL(rawUrl);
105 const protocol = (url.protocol.match(/^https/)) ? https : http;
106 const keepAliveAgent = new protocol.Agent({
107 keepAlive: true,
108 keepAliveMsecs: 30000,
109 maxSockets: opts.parallelism
110 });
111 // Split the URL to separate service from database
112 // Use origin as the "base" to remove auth elements
113 const actUrl = new URL(url.pathname.substr(0, url.pathname.lastIndexOf('/')), url.origin);
114 const dbName = url.pathname.substr(url.pathname.lastIndexOf('/') + 1);
115 let authenticator;
116 // Default to cookieauth unless an IAM key is provided
117 if (opts.iamApiKey) {
118 const iamAuthOpts = { apikey: opts.iamApiKey };
119 if (opts.iamTokenUrl) {
120 iamAuthOpts.url = opts.iamTokenUrl;
121 }
122 authenticator = new IamAuthenticator(iamAuthOpts);
123 } else if (url.username) {
124 authenticator = new CouchdbSessionAuthenticator({
125 username: url.username,
126 password: url.password
127 });
128 } else {
129 authenticator = new NoAuthAuthenticator();
130 }
131 const serviceOpts = {
132 authenticator: authenticator,
133 timeout: opts.requestTimeout,
134 // Axios performance options
135 maxContentLength: -1
136 };
137 if (url.protocol === 'https') {
138 serviceOpts.httpsAgent = keepAliveAgent;
139 } else {
140 serviceOpts.httpAgent = keepAliveAgent;
141 }
142 const service = new CloudantV1(serviceOpts);
143 // Configure retries
144 const maxRetries = 2; // for 3 total attempts
145 service.getHttpClient().defaults.raxConfig = {
146 // retries for status codes
147 retry: maxRetries,
148 // retries for non-response e.g. ETIMEDOUT
149 noResponseRetries: maxRetries,
150 backoffType: 'exponential',
151 httpMethodsToRetry: ['GET', 'HEAD', 'POST'],
152 statusCodesToRetry: [
153 [429, 429],
154 [500, 599]
155 ],
156 shouldRetry: err => {
157 const cfg = retryPlugin.getConfig(err);
158 // cap at max retries regardless of response/non-response type
159 if (cfg.currentRetryAttempt >= maxRetries) {
160 return false;
161 } else {
162 return retryPlugin.shouldRetryRequest(err);
163 }
164 },
165 instance: service.getHttpClient()
166 };
167 retryPlugin.attach(service.getHttpClient());
168
169 service.setServiceUrl(actUrl.toString());
170 if (authenticator instanceof CouchdbSessionAuthenticator) {
171 // Awkward workaround for known Couch issue with compression on _session requests
172 // It is not feasible to disable compression on all requests with the amount of
173 // data this lib needs to move, so override the property in the tokenManager instance.
174 authenticator.tokenManager.requestWrapperInstance.compressRequestData = false;
175 }
176 if (authenticator.tokenManager && authenticator.tokenManager.requestWrapperInstance) {
177 authenticator.tokenManager.requestWrapperInstance.axiosInstance.interceptors.response.use(null, errorHelper);
178 }
179 // Add error interceptors to put URLs in error messages
180 service.getHttpClient().interceptors.response.use(null, errorHelper);
181
182 // Add request interceptor to add user-agent (adding it with custom request headers gets overwritten)
183 service.getHttpClient().interceptors.request.use(function(requestConfig) {
184 requestConfig.headers['User-Agent'] = userAgent;
185 return requestConfig;
186 }, null);
187
188 return { service: service, db: dbName, url: actUrl.toString() };
189 }
190};