Skip to content

Commit bc7f4c9

Browse files
csumana19Copilot
andcommitted
Add rate limit header wrapper for all API responses
Introduce AdoHttpClient and AdoRestClient wrappers that automatically extract rate limit headers and attach them as a rateLimit property on response objects. This avoids needing to regenerate all API clients. - Add RateLimitUtils.ts with shared extractRateLimitHeaders function - Add AdoHttpClientBases.ts with AdoHttpClient and AdoRestClient wrappers - Update ClientApiBases.ts and WebApi.ts to use new wrappers - Add unit test for rate limit header extraction - Bump version to 15.1.4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d78472 commit bc7f4c9

8 files changed

Lines changed: 136 additions & 34 deletions

File tree

api/AdoHttpClientBases.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import { extractRateLimitHeaders } from './RateLimitUtils';
5+
6+
import * as rm from 'typed-rest-client/RestClient';
7+
import * as hm from 'typed-rest-client/HttpClient';
8+
import { IHeaders, IHttpClientResponse } from 'typed-rest-client/Interfaces';
9+
10+
/**
11+
* AdoHttpClient that extracts rate limit headers and attaches them to the response object
12+
*/
13+
export class AdoHttpClient extends hm.HttpClient {
14+
public async request(verb: string, requestUrl: string, data: string | NodeJS.ReadableStream, headers: IHeaders): Promise<IHttpClientResponse> {
15+
const res = await super.request(verb, requestUrl, data, headers);
16+
17+
const resHeaders = res?.message?.headers;
18+
19+
extractRateLimitHeaders(resHeaders, res);
20+
extractRateLimitHeaders(resHeaders, res?.message);
21+
22+
return res;
23+
}
24+
}
25+
26+
/**
27+
* AdoRestClient that extracts rate limit headers and attaches them to the response/result objects
28+
*/
29+
export class AdoRestClient extends rm.RestClient {
30+
protected async processResponse<T>(res: hm.HttpClientResponse, options: rm.IRequestOptions): Promise<rm.IRestResponse<T>> {
31+
const headers = res?.message?.headers;
32+
33+
try {
34+
const response = await super.processResponse(res, options);
35+
if (response && response.result && typeof response.result === 'object') {
36+
extractRateLimitHeaders(headers, response.result);
37+
}
38+
return response as rm.IRestResponse<T>;
39+
} catch (err: any) {
40+
// Use the original response headers captured before super.processResponse,
41+
// because the base implementation may not populate err.responseHeaders
42+
// when the response body is not valid JSON (e.g., HTML error pages on 429).
43+
extractRateLimitHeaders(headers, err);
44+
if (err?.result && typeof err.result === 'object') {
45+
extractRateLimitHeaders(headers, err.result);
46+
}
47+
return Promise.reject(err);
48+
}
49+
}
50+
}

api/ClientApiBases.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import vsom = require('./VsoClient');
55
import VsoBaseInterfaces = require('./interfaces/common/VsoBaseInterfaces');
66
import serm = require('./Serialization');
7+
import AdoHttpClientBases = require('./AdoHttpClientBases');
8+
import { extractRateLimitHeaders } from './RateLimitUtils';
79
import * as rm from 'typed-rest-client/RestClient';
810
import * as hm from 'typed-rest-client/HttpClient';
911

@@ -20,8 +22,8 @@ export class ClientApiBase {
2022
constructor(baseUrl: string, handlers: VsoBaseInterfaces.IRequestHandler[], userAgent: string, options?: VsoBaseInterfaces.IRequestOptions) {
2123
this.baseUrl = baseUrl;
2224

23-
this.http = new hm.HttpClient(userAgent, handlers, options);
24-
this.rest = new rm.RestClient(userAgent, null, handlers, options);
25+
this.http = new AdoHttpClientBases.AdoHttpClient(userAgent, handlers, options);
26+
this.rest = new AdoHttpClientBases.AdoRestClient(userAgent, null, handlers, options);
2527

2628
this.vsoClient = new vsom.VsoClient(baseUrl, this.rest);
2729
this.userAgent = userAgent;
@@ -50,30 +52,6 @@ export class ClientApiBase {
5052
}
5153

5254
public extractRateLimitHeaders(headers: any, target: any): void {
53-
if (!headers || !target) {
54-
return;
55-
}
56-
57-
const rateLimit: VsoBaseInterfaces.RateLimit = {};
58-
59-
if (headers['x-ratelimit-resource']) {
60-
rateLimit.resource = headers['x-ratelimit-resource'];
61-
}
62-
if (headers['x-ratelimit-delay']) {
63-
rateLimit.delay = parseFloat(headers['x-ratelimit-delay']);
64-
}
65-
if (headers['x-ratelimit-limit']) {
66-
rateLimit.limit = parseInt(headers['x-ratelimit-limit'], 10);
67-
}
68-
if (headers['x-ratelimit-remaining']) {
69-
rateLimit.remaining = parseInt(headers['x-ratelimit-remaining'], 10);
70-
}
71-
if (headers['x-ratelimit-reset']) {
72-
rateLimit.reset = parseInt(headers['x-ratelimit-reset'], 10);
73-
}
74-
if (headers['retry-after']) {
75-
rateLimit.retryAfter = parseInt(headers['retry-after'], 10);
76-
}
77-
target.rateLimit = rateLimit;
55+
extractRateLimitHeaders(headers, target);
7856
}
7957
}

api/RateLimitUtils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import VsoBaseInterfaces = require('./interfaces/common/VsoBaseInterfaces');
5+
6+
/**
7+
* Extracts rate limit headers from the provided headers and attaches them to the target object
8+
* @param headers - source headers
9+
* @param target - target object to attach rate limit info to
10+
*/
11+
export function extractRateLimitHeaders(headers: any, target: any): void {
12+
if (!headers || !target) {
13+
return;
14+
}
15+
16+
const rateLimit: VsoBaseInterfaces.RateLimit = {};
17+
18+
if (headers['x-ratelimit-resource']) {
19+
rateLimit.resource = headers['x-ratelimit-resource'];
20+
}
21+
if (headers['x-ratelimit-delay']) {
22+
rateLimit.delay = parseFloat(headers['x-ratelimit-delay']);
23+
}
24+
if (headers['x-ratelimit-limit']) {
25+
rateLimit.limit = parseInt(headers['x-ratelimit-limit'], 10);
26+
}
27+
if (headers['x-ratelimit-remaining']) {
28+
rateLimit.remaining = parseInt(headers['x-ratelimit-remaining'], 10);
29+
}
30+
if (headers['x-ratelimit-reset']) {
31+
rateLimit.reset = parseInt(headers['x-ratelimit-reset'], 10);
32+
}
33+
if (headers['retry-after']) {
34+
rateLimit.retryAfter = parseInt(headers['retry-after'], 10);
35+
}
36+
target.rateLimit = rateLimit;
37+
}

api/WebApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import basicm = require('./handlers/basiccreds');
3737
import bearm = require('./handlers/bearertoken');
3838
import ntlmm = require('./handlers/ntlm');
3939
import patm = require('./handlers/personalaccesstoken');
40-
40+
import AdoHttpClientBases = require('./AdoHttpClientBases');
4141
import * as rm from 'typed-rest-client/RestClient';
4242
import vsom = require('./VsoClient');
4343
import lim = require("./interfaces/LocationsInterfaces");
@@ -172,7 +172,7 @@ export class WebApi {
172172
userAgent = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
173173
}
174174
}
175-
this.rest = new rm.RestClient(userAgent, null, [this.authHandler], this.options);
175+
this.rest = new AdoHttpClientBases.AdoRestClient(userAgent, null, [this.authHandler], this.options);
176176
this.vsoClient = new vsom.VsoClient(defaultUrl, this.rest);
177177
}
178178

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "azure-devops-node-api",
33
"description": "Node client for Azure DevOps and TFS REST APIs",
4-
"version": "15.1.3",
4+
"version": "15.1.4",
55
"main": "./WebApi.js",
66
"types": "./WebApi.d.ts",
77
"scripts": {

test/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/units/tests.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import os = require('os');
55
import vsom = require('../../_build/VsoClient');
66
import WebApi = require('../../_build/WebApi');
77
import * as rm from 'typed-rest-client/RestClient';
8-
import { ApiResourceLocation } from '../../_build/interfaces/common/VsoBaseInterfaces';
8+
import { ApiResourceLocation, RateLimit } from '../../_build/interfaces/common/VsoBaseInterfaces';
99
import semver = require('semver');
10+
import { IRestResponse } from 'typed-rest-client';
1011

1112
describe('VSOClient Units', function () {
1213
let rest: rm.RestClient;
@@ -316,6 +317,42 @@ describe('WebApi Units', function () {
316317
assert.equal(userAgent, myWebApi.rest.client.userAgent, 'User agent should be: ' + userAgent);
317318
});
318319

320+
it('AdoRestClient attaches rate limit headers to responses', async () => {
321+
// Arrange
322+
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
323+
324+
nock('https://dev.azure.com', {
325+
reqheaders: {
326+
'accept': 'application/json',
327+
'user-agent': userAgent,
328+
},
329+
})
330+
.defaultReplyHeaders({
331+
'x-ratelimit-resource': 'core',
332+
'x-ratelimit-delay': '0.5',
333+
'x-ratelimit-limit': '1000',
334+
'x-ratelimit-remaining': '999',
335+
'x-ratelimit-reset': '1625078400',
336+
'retry-after': '1',
337+
})
338+
.get('/test')
339+
.reply(200, { success: true });
340+
341+
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://dev.azure.com', WebApi.getBasicHandler('user', 'password'));
342+
343+
// Act
344+
const response = (await myWebApi.rest.get<{ success: boolean, rateLimit?: RateLimit }>('https://dev.azure.com/test')) as IRestResponse<{ success: boolean, rateLimit?: RateLimit }>;
345+
// Assert
346+
assert(response.result?.success === true, 'Response body should indicate success');
347+
assert(response.result?.rateLimit, 'Response should have rateLimit property');
348+
assert.equal(response.result.rateLimit?.resource, 'core', 'Rate limit resource should be "core"');
349+
assert.equal(response.result.rateLimit?.delay, 0.5, 'Rate limit delay should be 0.5');
350+
assert.equal(response.result.rateLimit?.limit, 1000, 'Rate limit limit should be 1000');
351+
assert.equal(response.result.rateLimit?.remaining, 999, 'Rate limit remaining should be 999');
352+
assert.equal(response.result.rateLimit?.reset, 1625078400, 'Rate limit reset should be 1625078400');
353+
assert.equal(response.result.rateLimit?.retryAfter, 1, 'Rate limit retryAfter should be 1');
354+
});
355+
319356
it('sets the user agent correctly when request settings are not specified', async () => {
320357
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://microsoft.com', WebApi.getBasicHandler('user', 'password'), undefined);
321358
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;

0 commit comments

Comments
 (0)