Im using NestJS. I want to get all data from paginated API (i dont know the total page). Right now im using while loop to get all the data until the API returns 204 No Content, this is my code so far:
async getProduct() {
let productFinal: ProductItem[] = [];
let products: ProductItem[] = [];
let offset = 1;
let state = COLLECTING_STATE.InProgress;
let retryCount = 1;
do {
const path = `product?limit=50&offset=${offset}`;
products = await this.httpService
.get(path, { headers, validateStatus: null })
.pipe(
concatMap((response) => {
// if the statusCode is "204", the loop is complete
if (response.status === 204) {
state = COLLECTING_STATE.Finish;
}
// check if the response is error
if (response.status < 200 || response.status >= 300) {
// log error
Logger.error(
`[ERROR] Error collecting product on offset: ${offset}. StatusCode: ${
response.status
}. Error: ${JSON.stringify(response.data)}. Retrying... (${retryCount})`,
undefined,
'Collect Product'
);
// increment the retryCount
retryCount++;
// return throwError to trigger retry event
return throwError(`[ERROR] Received status ${response.status} from HTTP call`);
}
// return the data if OK
return of(response.data.item);
}),
catchError((err) => {
if (err?.code) {
// log error
Logger.error(
`Connection error: ${err?.code}. Retrying... (${retryCount})`,
undefined,
'Collect Product'
);
// increment the retryCount
retryCount++;
}
return throwError(err);
}),
// retry three times
retry(3),
// if still error, then stop the loop
catchError((err) => {
Logger.error(
`[ERROR] End retrying. Error: ${err?.code ?? err}`,
undefined,
'Collect Product'
);
state = COLLECTING_STATE.Finish;
return of(err);
})
)
.toPromise();
// set retryCount to 1 again
retryCount = 1;
// check if products is defined
if (products?.length > 0) {
// if so, push the product to final variable
productFinal = union(products, productFinal);
}
// increment the offset
offset++;
// and loop while the state is not finish
} while ((state as COLLECTING_STATE) !== COLLECTING_STATE.Finish);
return productFinal;
}
The endpoint product?limit=50&offset=${offset} is from third-party service, it doesn't have one endpoint to grab all the data so this is the only way, it has a maximum limit of 50 per offset, and it didn't have a nextPage or totalPage information on the response so i have to make offset variable and increment it after the previous request is complete.
How do I replace the while loop with the RxJS operator? And can it be optimized to make more than one request at a time (maybe four or five), thus taking less time to get all data?
Based on answer from RxJS Observable Pagination, but increment offset every time request is made:
const { of, timer, defer, EMPTY, from, concat } = rxjs; // = require("rxjs")
const { map, tap, mapTo, mergeMap, take } = rxjs.operators; // = require("rxjs/operators")
// simulate network request
function fetchPage({ limit, offset }) {
// 204 resposne
if (offset > 20) {
return of({ status: 204, items: null });
}
// regular data response
return timer(100).pipe(
tap(() =>
console.log(`-> fetched elements from ${offset} to ${offset+limit}`)
),
mapTo({
status: 200,
items: Array.from({ length: limit }).map((_, i) => offset + i)
})
);
}
const limit = 10;
function getItems(offset = 0) {
return defer(() => fetchPage({ limit, offset })).pipe(
mergeMap(({ status, items }) => {
if (status === 204) {
return EMPTY;
}
const items$ = from(items);
const next$ = getItems(offset + limit);
return concat(items$, next$);
})
);
}
// process only first 100 items, without fetching all of the data
getItems()
.pipe(take(100))
.subscribe({
next: console.log,
error: console.error,
complete: () => console.log("complete")
});
<script src="https://unpkg.com/rxjs#6.6.2/bundles/rxjs.umd.min.js"></script>
Regarding possible optimization to make parallel requests - I don't think it will work well. Instead you could show data progressively, as soon as items are loading. Or change API as was suggested in the comments.
Related
I'm using rxjs to connect to a WebSocket service, and in case of failure, I want to retry 3 times, wait 30 seconds, then repeat infinitely, how can I do this?
I found a solution, first, create the following operator:
function retryWithDelay<T>(
repetitions: number,
delay: number
): (a: Observable<T>) => Observable<T> {
let count = repetitions;
return (source$: Observable<T>) =>
source$.pipe(
retryWhen((errors) =>
errors.pipe(
delayWhen(() => {
count--;
if (count === 0) {
count = repetitions;
return timer(delay);
}
return timer(0);
})
)
)
);
}
Then, use use it like this:
function connect(url: string) {
return webSocket({ url })
.pipe(retryWithDelay(3, 30000));
}
You can do this by doing the following:
//emit value every 200ms
const source = Rx.Observable.interval(200);
//output the observable
const example = source
.map(val => {
if (val > 5) {
throw new Error('The request failed somehow.');
}
return val;
})
.retryWhen(errors => errors
//log error message
.do(val => console.log(`Some error that occur ${val}, pauze for 30 seconds.`))
//restart in 30 seconds
.delayWhen(val => Rx.Observable.timer(30 * 1000))
);
const subscribe = example
.subscribe({
next: val => console.log(val),
error: val => console.log(`This will never happen`)
});
See the working example: https://jsbin.com/goxowiburi/edit?js,console
Be aware that this is an infinite loop and you are not introducing unintended consequences into your code.
Here is an example of code
The first thing I do is intercept the request, then I want to wait until response will contain expected status in body. But the test is failing after default timeout - 30000ms with the error like this:
Timed out retrying after 30000ms: expected 'Running' to equal 'Completed'
So the test is failing because status is Running, and the expected is Completed. how to increase timeout in this case?
cy.intercept('GET', Cypress.config().baseUrl + 'api/scans/' + scanID).as('getStatus');
cy.visit('/')
cy.wait('#getStatus', {responseTimeout: 80000}).its('response.body.status')
.should('eq', 'Completed')
Continuing from Hiram's answer, if you don't know how many requests to wait for you can use a function to repeat the wait until the desired status arrives.
Of course, the "Completed" status may never arrive so you want to set a maximum number of request/responses to check (in lieu of the timeout).
To simulate your situation, I used a simple web site that makes three requests in 300ms intervals.
The response body has an id which happens to be the same as the parameter in the request, so we want to wait for response.body.id === 3.
Simple app with 3 requests
<script>
setTimeout(() => fetch('https://jsonplaceholder.typicode.com/todos/1'), 300)
setTimeout(() => fetch('https://jsonplaceholder.typicode.com/todos/2'), 600)
setTimeout(() => fetch('https://jsonplaceholder.typicode.com/todos/3'), 900)
</script>
Test
it('waits for a particular response', () => {
cy.visit('../app/intercept-last.html')
cy.intercept('**/jsonplaceholder.typicode.com/todos/*')
.as('todos')
function waitFor(alias, partialResponse, maxRequests, level = 0) {
if (level === maxRequests) {
throw `${maxRequests} requests exceeded` // fail the test
}
cy.wait(alias).then(interception => {
const isMatch = Cypress._.isMatch(interception.response, partialResponse)
if (!isMatch) {
waitFor(alias, partialResponse, maxRequests, level+1)
}
})
}
waitFor('#todos', { body: { id: 3 } }, 100) // if no error, value has arrived
cy.get('#todos.last') // now use get() instead of wait()
.its('response.body.id').should('eq', 3)
})
Your test
cy.intercept('GET', Cypress.config().baseUrl + 'api/scans/' + scanID)
.as('getStatus')
cy.visit('/')
function waitFor(alias, partialResponse, maxRequests, level = 0) {
if (level === maxRequests) {
throw `${maxRequests} requests exceeded`
}
cy.wait(alias).then(interception => {
const isMatch = Cypress._.isMatch(interception.response, partialResponse)
if (!isMatch) {
waitFor(alias, partialResponse, maxRequests, level+1)
}
})
}
const timeout = 80_000 // timeout you want to apply
const pollingRate = 500 // rate at which app sends requests
const maxRequests = (timeout / pollingRate) + 10; // number of polls plus a few more
waitFor('#getStatus', { body: { status: 'Completed' } }, maxRequests)
cy.get('#getStatus.last')
.its('response.body.status').should('eq', 'Completed')
Another solution for the same need with a polling process :
cy.intercept('GET', Cypress.config().baseUrl + 'api/scans/' + scanID).as('polling');
const waitPolling = (res) => {
const {response: {body: {status }}} = res;
if (status !== 'Completed') {
cy.wait('#polling').then(waitPolling);
}
}
cy.wait('#polling').then(waitPolling);
The response timeout isn't ignored, a response is getting caught within the timeout you gave, but it has a different property to the one you expected.
I suppose since the status can be 'Running' or 'Completed' that two responses are coming through. (Maybe also two requests)
I suggest you wait twice
cy.wait('#getStatus', {responseTimeout: 80000})
.its('response.body.status').should('eq', 'Running')
cy.wait('#getStatus', {responseTimeout: 80000})
.its('response.body.status').should('eq', 'Completed')
If no luck with that, take a look at what is happening on the network tab.
the timeout that I defined does not throw any error when the duration parameter I defined is greater than 7000 ms. what is strange is that the timeout operator works well in my code from 0 to 7000 ms
pay(billing: Billing): Observable {
const httpOptions = {
headers: new HttpHeaders({
// 'Access-Control-Allow-Origin':'*'
}),
params: new HttpParams()
.append('timezone', billing.timezone)
.append('mode', billing.mode)
.append('responseFailURL', billing.responseFailURL)
.append('responseSuccessURL', billing.responseSuccessURL)
.append('hash', billing.hash)
.append('txndatetime', billing.txndatetime)
.append('chargetotal', billing.chargetotal.toString())
.append('storename', billing.storename.toString())
.append('currency', billing.currency.toString())
};
// Sending required payment infrmations to Authipay host url
return forkJoin(
of(2), timer(2000).pipe(mergeMap(value => this.getPayementStatus(billing.txndatetime))).pipe( timeout(7500))
).pipe(
map(
([articles, authorOfTheMonth]) => {
console.log(authorOfTheMonth);
return authorOfTheMonth;
}
)
).subscribe(
resp => {
this.router.navigate(['success'], { relativeTo: this.route });
} else {
form.setErrors({ paymentFailed: true });
this.alertify.error(this.translate.instant('error.payment'));
}
},
error => {
if (error instanceof TimeoutError) {
this.alertify.error(error.message);
} else {
this.alertify.error(this.translate.instant('error.payment'));
}
}
);
timeout seems to work as expected to me.
I wrote a test here where I replaced your this.getPayementStatus(billing.txndatetime)) function with a :
simulated response
const simulateResponseTime = (timeInMS)=> timer(timeInMS); // in milliseconds
Which will return a response in delayOfResponse milliseconds. With this tool we can test what happens when the response takes more time than timeout threshold:
Simulation parameters
const timeoutThreshold = 7500; // in ms
const delayOfResponse = 200; //in ms
Finally, a minimalist version of
Your code
forkJoin(of(2), timer(2000).pipe(
mergeMap(value => simulateResponseTime(delayOfResponse))
).pipe(timeout(timeoutThreshold))
).pipe(
...
).subscribe(
resp => {
console.log('Success')
},
error => {
console.log('Error message :', error.message)
console.log('Error type :', error.name)
console.log('Is a TimeoutError :', error.name === 'TimeoutError' )
}
);
I have following code:
private getUsers(page, result) {
result = result||[];
return this.http.get(API_URL + '/users?page=1')
.pipe(map(response => {
const response_filter = response.json();
const users = response_filter['data'];
const pages = response_filter['total_pages'];
Array.prototype.push.apply(result, users.map((user) => new User(user)));
while (page != pages)
{
this.http.get(API_URL + '/users?page=' + page)
.pipe(map(resp => {
console.log('test');
const response_filter = resp.json();
const users = response_filter['data'];
Array.prototype.push.apply(result, users.map((user) => new User(user)));
return result;
}))
.pipe(catchError(val => of(`Caught inner error: ${val}`)));
page += 1;
}
return result;
}))
.pipe(catchError(val => of(`Caught error: ${val}`)));
}
Code works good until console.log('test'). This log doesn't get shown, but while loop iterates fine.
Previously i tried the same function, but in recursive way. There was the same problem.
The best way to do this is to create a single observable which represents all of the requests you want to make, using flatMap and forkJoin operators. There are a number of problems with the asynchronous operations in your code, meaning that the returned result will not include the results of the inner HTTP requests.
I would propose the following:
private getUsers(page, result) {
return this.http.get(API_URL + '/users?page=1')
.pipe(
flatMap((response) => {
const response_filter = response.json();
const users = response_filter['data'];
const pages = response_filter['total_pages'];
let firstPageUsers: User[] = users.map((user) => new User(user));
let getAllUsers: Observable<User[]>[];
getAllUsers.push(of(firstPageUsers));
while (page < pages)
{
getAllUsers.push(this.http.get(API_URL + '/users?page=' + page)
.pipe(
map(resp => {
console.log('test');
const response_filter = resp.json();
const users = response_filter['data'];
return users.map((user) => new User(user));
}),
// You need to decide if this is how you want errors
// handled, it doesn't seem too sensible to me:
catchError((err) => {
console.log(`Caught inner error: ${err}`);
return of([]); // needs to return type Observable<User[]>
})
)
);
page += 1;
}
return forkJoin(getAllUsers);
}),
map((allResponses) => {
// allResponses will be an array of User arrays from
// all of the observables within the forkJoin, so now
// we can iterate over all of those to create a single
// array containing all of the results.
result = result||[];
allResponses.forEach((responseUsers) => {
Array.prototype.push.apply(result, responseUsers);
});
return result;
}),
catchError((err) => {
console.log(`Caught outer error: ${err}`);
of(null); // Or whatever - again, think about your error cases.
})
);
}
Now wherever you are calling getUsers, when you subscribe to this observable it should resolve all of the inner queries as well.
Marks answer is great, but I already solved my problem (maybe not in the good way, but solved it) using Martin comment (using subscribe). Firstly I subscribe for a "get pages count" request and then I'm subscribing to "get users" request in a while loop.
I'm new in angular, so maybe someone will answer a question "Must I use unsubscribe here?"
this._dataSub0 = this.userDataService.getPages().subscribe((pages) => {
var page_num = pages;
var i = 1;
while (i < page_num) {
this._dataSub = this.userDataService
.getAllUsers()
.subscribe(
(users) => {
for (let us of users) {
this.users.push(us);
}
}
);
i++;
}
});
public getAllUsers(page): Observable<User[]> {
return this.getUsers(page);
}
private getUsers(page) {
var result = result||[];
return this.http.get(API_URL + '/users?page=' + page)
.pipe(map(response => {
const response_filter = response.json();
const users = response_filter['data'];
const pages = response_filter['total_pages']
if(pages == page)
return null;
Array.prototype.push.apply(result, users.map((user) => new User(user)));
return result;
}))
.pipe(catchError(val => of(`Caught error: ${val}`)));
}
public getPages(): Observable<number> {
var result;
return this.http.get(API_URL + '/users?page=0')
.pipe(map(response => {
const response_filter = response.json();
const pages = response_filter['total_pages']
return pages;
}))
.pipe(catchError(val => of(`Caught error: ${val}`)));
}
have an observable that returns arrays/lists of things: Observable
And I have a usecase where is is a pretty costly affair for the downstream consumer of this observable to have more items added to this list. So I'd like to slow down the amount of additions that are made to this list, but not loose any.
Something like an operator that takes this observable and returns another observable with the same signature, but whenever a new list gets pushed on it and it has more items than last time, then only one or a few are added at a time.
So if the last push was a list with 3 items and next push has 3 additional items with 6 items in total, and the batch size is 1, then this one list push gets split into 3 individual pushes of lists with lengths: 4, 5, 6
So additions are batched, and this way the consumer can more easily keep up with new additions to the list. Or the consumer doesn't have to stall for so long each time while processing additional items in the array/list, because the additions are split up and spread over a configurable size of batches.
I made an addAdditionalOnIdle operator that you can apply to any rxjs observable using the pipe operator. It takes a batchSize parameter, so you can configure the batch size. It also takes a dontBatchAfterThreshold, which stops batching of the list after a certain list size, which was useful for my purposes. The result also contains a morePending value, which you can use to show a loading indicator while you know more data is incomming.
The implementation uses the new requestIdleCallback function internally to schedule the batched pushes of additional items when there is idle time in the browser. This function is not available in IE or Safari yet, but I found this excelent polyfill for it, so you can use it today anyways: https://github.com/aFarkas/requestIdleCallback :)
See the implementation and example usage of addAdditionalOnIdle below:
const { NEVER, of, Observable } = rxjs;
const { concat } = rxjs.operators;
/**
* addAdditionalOnIdle
*
* Only works on observables that produce values that are of type Array.
* Adds additional elements on window.requestIdleCallback
*
* #param batchSize The amount of values that are added on each idle callback
* #param dontBatchAfterThreshold Return all items after amount of returned items passes this threshold
*/
function addAdditionalOnIdle(
batchSize = 1,
dontBatchAfterThreshold = 22,
) {
return (source) => {
return Observable.create((observer) => {
let idleCallback;
let currentPushedItems = [];
let lastItemsReceived = [];
let sourceSubscription = source
.subscribe({
complete: () => {
observer.complete();
},
error: (error) => {
observer.error(error);
},
next: (items) => {
lastItemsReceived = items;
if (idleCallback) {
return;
}
if (lastItemsReceived.length > currentPushedItems.length) {
const idleCbFn = () => {
if (currentPushedItems.length > lastItemsReceived.length) {
observer.next({
morePending: false,
value: lastItemsReceived,
});
idleCallback = undefined;
return;
}
const to = currentPushedItems.length + batchSize;
const last = lastItemsReceived.length;
if (currentPushedItems.length < dontBatchAfterThreshold) {
for (let i = 0 ; i < to && i < last ; i++) {
currentPushedItems[i] = lastItemsReceived[i];
}
} else {
currentPushedItems = lastItemsReceived;
}
if (currentPushedItems.length < lastItemsReceived.length) {
idleCallback = window.requestIdleCallback(() => {
idleCbFn();
});
} else {
idleCallback = undefined;
}
observer.next({
morePending: currentPushedItems.length < lastItemsReceived.length,
value: currentPushedItems,
});
};
idleCallback = window.requestIdleCallback(() => {
idleCbFn();
});
} else {
currentPushedItems = lastItemsReceived;
observer.next({
morePending: false,
value: currentPushedItems,
});
}
},
});
return () => {
sourceSubscription.unsubscribe();
sourceSubscription = undefined;
lastItemsReceived = undefined;
currentPushedItems = undefined;
if (idleCallback) {
window.cancelIdleCallback(idleCallback);
idleCallback = undefined;
}
};
});
};
}
function sleep(milliseconds) {
var start = new Date().getTime();
for (var i = 0; i < 1e7; i++) {
if ((new Date().getTime() - start) > milliseconds){
break;
}
}
}
let testSource = of(
[1,2,3],
[1,2,3,4,5,6],
).pipe(
concat(NEVER)
);
testSource
.pipe(addAdditionalOnIdle(2))
.subscribe((list) => {
// Simulate a slow synchronous consumer with a busy loop sleep implementation
sleep(1000);
document.body.innerHTML += "<p>" + list.value + "</p>";
});
<script src="https://unpkg.com/rxjs#6.5.3/bundles/rxjs.umd.js"></script>