Skip to content

Commit f55e597

Browse files
Split large batch lookups into chunks with bounded concurrency like the Go client
1 parent 49e9b38 commit f55e597

3 files changed

Lines changed: 451 additions & 28 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,29 @@ Rate limiting is opt-in per API key on Ipregistry, which is why
195195
`retryOnTooManyRequests` defaults to false. Enable it if your key is
196196
rate limited.
197197

198+
## Batch lookups
199+
200+
`batchLookupIps` and `batchLookupAsns` look up many values in a single call:
201+
202+
```typescript
203+
const response = await client.batchLookupIps(['1.1.1.1', '8.8.8.8']);
204+
```
205+
206+
Inputs larger than the API's per-request limit (1024 values) are split
207+
automatically into multiple requests dispatched with bounded concurrency
208+
(4 requests in flight by default), and results are returned in input order.
209+
Cached entries are served locally; only the remainder is requested. Both knobs
210+
are configurable:
211+
212+
```typescript
213+
const client = new IpregistryClient(
214+
new IpregistryConfigBuilder('YOUR_API_KEY')
215+
.withMaxBatchSize(256) // values per request, capped at 1024
216+
.withBatchConcurrency(1) // sequential dispatch, gentler on rate-limited keys
217+
.build()
218+
);
219+
```
220+
198221
## Enabling hostname lookup
199222

200223
By default, the Ipregistry API does not return information about the hostname a given IP address resolves to.

src/index.ts

Lines changed: 188 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import {
1818
ApiResponse,
19+
ApiResponseCredits,
20+
ApiResponseThrottling,
1921
BatchResult,
2022
DefaultRequestHandler,
2123
IpregistryRequestHandler,
@@ -32,6 +34,12 @@ import { IpregistryOption } from './options.js'
3234

3335
import { isApiError, LookupError } from './errors.js'
3436

37+
/**
38+
* The maximum number of IP addresses or ASNs the Ipregistry API accepts in a
39+
* single batch request.
40+
*/
41+
export const DEFAULT_MAX_BATCH_SIZE = 1024
42+
3543
/**
3644
* Represents the configuration for the Ipregistry API client.
3745
* This class holds the API key, base URL, and timeout setting used for API requests.
@@ -81,6 +89,19 @@ export class IpregistryConfig {
8189
*/
8290
public readonly retryOnTooManyRequests: boolean = false
8391

92+
/**
93+
* The maximum number of values sent in a single batch request. Larger
94+
* batches are split into this many values per request. Capped at
95+
* `DEFAULT_MAX_BATCH_SIZE` (the API limit).
96+
*/
97+
public readonly maxBatchSize: number = DEFAULT_MAX_BATCH_SIZE
98+
99+
/**
100+
* How many batch sub-requests are dispatched concurrently when a batch is
101+
* large enough to be split into chunks. Defaults to 4.
102+
*/
103+
public readonly batchConcurrency: number = 4
104+
84105
/**
85106
* Constructs a new `IpregistryConfig` instance.
86107
* @param apiKey The API key for authenticating requests.
@@ -90,6 +111,8 @@ export class IpregistryConfig {
90111
* @param retryInterval Optional. The base backoff between retries in milliseconds.
91112
* @param retryOnServerError Optional. Whether 5xx responses are retried.
92113
* @param retryOnTooManyRequests Optional. Whether 429 responses are retried.
114+
* @param maxBatchSize Optional. The maximum number of values per batch request.
115+
* @param batchConcurrency Optional. How many batch sub-requests run concurrently.
93116
*/
94117
constructor(
95118
apiKey: string,
@@ -99,6 +122,8 @@ export class IpregistryConfig {
99122
retryInterval?: number,
100123
retryOnServerError?: boolean,
101124
retryOnTooManyRequests?: boolean,
125+
maxBatchSize?: number,
126+
batchConcurrency?: number,
102127
) {
103128
this.apiKey = apiKey
104129

@@ -125,6 +150,18 @@ export class IpregistryConfig {
125150
if (retryOnTooManyRequests !== undefined) {
126151
this.retryOnTooManyRequests = retryOnTooManyRequests
127152
}
153+
154+
if (
155+
maxBatchSize !== undefined &&
156+
maxBatchSize > 0 &&
157+
maxBatchSize <= DEFAULT_MAX_BATCH_SIZE
158+
) {
159+
this.maxBatchSize = maxBatchSize
160+
}
161+
162+
if (batchConcurrency !== undefined && batchConcurrency > 0) {
163+
this.batchConcurrency = batchConcurrency
164+
}
128165
}
129166
}
130167

@@ -148,6 +185,10 @@ export class IpregistryConfigBuilder {
148185

149186
private retryOnTooManyRequests: boolean = false
150187

188+
private maxBatchSize: number = DEFAULT_MAX_BATCH_SIZE
189+
190+
private batchConcurrency: number = 4
191+
151192
constructor(apiKey: string) {
152193
this.apiKey = apiKey
153194
}
@@ -222,6 +263,34 @@ export class IpregistryConfigBuilder {
222263
return this
223264
}
224265

266+
/**
267+
* Sets the maximum number of values sent in a single batch request. Batch
268+
* lookups split larger inputs into this many values per request. Values
269+
* are capped at `DEFAULT_MAX_BATCH_SIZE` (the API limit); a value <= 0 is
270+
* ignored.
271+
* @param maxBatchSize The maximum number of values per batch request.
272+
* @returns The `IpregistryConfigBuilder` instance for chaining.
273+
*/
274+
public withMaxBatchSize(maxBatchSize: number): IpregistryConfigBuilder {
275+
this.maxBatchSize = maxBatchSize
276+
return this
277+
}
278+
279+
/**
280+
* Sets how many batch sub-requests are dispatched concurrently when a
281+
* batch is large enough to be split into chunks. A value <= 0 is ignored.
282+
* Set it to 1 for strictly sequential dispatch, which is gentler on a
283+
* rate-limited API key.
284+
* @param batchConcurrency How many batch sub-requests run concurrently.
285+
* @returns The `IpregistryConfigBuilder` instance for chaining.
286+
*/
287+
public withBatchConcurrency(
288+
batchConcurrency: number,
289+
): IpregistryConfigBuilder {
290+
this.batchConcurrency = batchConcurrency
291+
return this
292+
}
293+
225294
public build(): IpregistryConfig {
226295
return new IpregistryConfig(
227296
this.apiKey,
@@ -231,6 +300,8 @@ export class IpregistryConfigBuilder {
231300
this.retryInterval,
232301
this.retryOnServerError,
233302
this.retryOnTooManyRequests,
303+
this.maxBatchSize,
304+
this.batchConcurrency,
234305
)
235306
}
236307
}
@@ -312,21 +383,12 @@ export class IpregistryClient {
312383
AutonomousSystem | LookupError
313384
>(asns.length)
314385

315-
let apiResponse: ApiResponse<
316-
BatchResult<AutonomousSystem | LookupError>
317-
> | null
318-
let freshAutonomousSystem: (AutonomousSystem | LookupError)[]
319-
320-
if (cacheMisses.length > 0) {
321-
apiResponse = await this.requestHandler.batchLookupAsns(
322-
cacheMisses,
323-
options,
324-
)
325-
freshAutonomousSystem = apiResponse.data.results
326-
} else {
327-
apiResponse = null
328-
freshAutonomousSystem = []
329-
}
386+
const apiResponse = await this.dispatchBatchChunks(cacheMisses, chunk =>
387+
this.requestHandler.batchLookupAsns(chunk, options),
388+
)
389+
const freshAutonomousSystem = apiResponse
390+
? apiResponse.data.results
391+
: []
330392

331393
let j = 0
332394
let k = 0
@@ -403,19 +465,10 @@ export class IpregistryClient {
403465
IpInfo | LookupError
404466
>(ips.length)
405467

406-
let apiResponse: ApiResponse<BatchResult<IpInfo | LookupError>> | null
407-
let freshIpInfo: (IpInfo | LookupError)[]
408-
409-
if (cacheMisses.length > 0) {
410-
apiResponse = await this.requestHandler.batchLookupIps(
411-
cacheMisses,
412-
options,
413-
)
414-
freshIpInfo = apiResponse.data.results
415-
} else {
416-
apiResponse = null
417-
freshIpInfo = []
418-
}
468+
const apiResponse = await this.dispatchBatchChunks(cacheMisses, chunk =>
469+
this.requestHandler.batchLookupIps(chunk, options),
470+
)
471+
const freshIpInfo = apiResponse ? apiResponse.data.results : []
419472

420473
let j = 0
421474
let k = 0
@@ -574,6 +627,113 @@ export class IpregistryClient {
574627
return this.cache
575628
}
576629

630+
/**
631+
* Resolves batch values, splitting inputs larger than `maxBatchSize` into
632+
* chunks dispatched with at most `batchConcurrency` requests in flight,
633+
* and concatenating their results in order. When a chunk fails, the first
634+
* error is thrown and no further chunk is dispatched (in-flight chunks
635+
* complete but their results are discarded). Returns null when there is
636+
* nothing to resolve.
637+
*/
638+
private async dispatchBatchChunks<V, R>(
639+
values: V[],
640+
request: (chunk: V[]) => Promise<ApiResponse<BatchResult<R>>>,
641+
): Promise<ApiResponse<BatchResult<R>> | null> {
642+
if (values.length === 0) {
643+
return null
644+
}
645+
646+
const { batchConcurrency, maxBatchSize } = this.config
647+
648+
if (values.length <= maxBatchSize) {
649+
return await request(values)
650+
}
651+
652+
const chunks: V[][] = []
653+
for (let start = 0; start < values.length; start += maxBatchSize) {
654+
chunks.push(values.slice(start, start + maxBatchSize))
655+
}
656+
657+
const responses: ApiResponse<BatchResult<R>>[] = new Array(
658+
chunks.length,
659+
)
660+
let nextChunk = 0
661+
let firstError: unknown = null
662+
663+
const worker = async () => {
664+
while (firstError === null) {
665+
const index = nextChunk++
666+
if (index >= chunks.length) {
667+
return
668+
}
669+
try {
670+
responses[index] = await request(chunks[index])
671+
} catch (error) {
672+
firstError = firstError ?? error
673+
return
674+
}
675+
}
676+
}
677+
678+
await Promise.all(
679+
Array.from(
680+
{ length: Math.min(batchConcurrency, chunks.length) },
681+
() => worker(),
682+
),
683+
)
684+
685+
if (firstError !== null) {
686+
throw firstError
687+
}
688+
689+
const results: R[] = []
690+
for (const response of responses) {
691+
results.push(...response.data.results)
692+
}
693+
694+
return {
695+
credits: IpregistryClient.aggregateCredits(responses),
696+
data: { results },
697+
throttling: IpregistryClient.mostConstrainedThrottling(responses),
698+
}
699+
}
700+
701+
private static aggregateCredits(
702+
responses: ApiResponse<unknown>[],
703+
): ApiResponseCredits {
704+
const consumed = responses
705+
.map(response => response.credits.consumed)
706+
.filter((value): value is number => value !== null)
707+
const remaining = responses
708+
.map(response => response.credits.remaining)
709+
.filter((value): value is number => value !== null)
710+
711+
return {
712+
consumed: consumed.length
713+
? consumed.reduce((total, value) => total + value, 0)
714+
: null,
715+
remaining: remaining.length ? Math.min(...remaining) : null,
716+
}
717+
}
718+
719+
private static mostConstrainedThrottling(
720+
responses: ApiResponse<unknown>[],
721+
): ApiResponseThrottling | null {
722+
let result: ApiResponseThrottling | null = null
723+
724+
for (const response of responses) {
725+
const throttling = response.throttling
726+
if (
727+
throttling &&
728+
(!result || throttling.remaining < result.remaining)
729+
) {
730+
result = throttling
731+
}
732+
}
733+
734+
return result
735+
}
736+
577737
private static buildCacheKey(
578738
primaryKey: string,
579739
options: IpregistryOption[],

0 commit comments

Comments
 (0)