Search code examples
javascriptpromisefetchuncaught-exceptioncatch-block

Uncaught (in promise); Thrown error in fetch() not being caught


I've already read tons of resources to try to help me on this. This gist did not solve it for me (https://github.com/github/fetch/issues/203#issuecomment-266034180). It also seemed like this (JavaScript Promises - reject vs. throw) would be my answer but it is not. Also this (Error thrown in awaited Promise not caught in catch block) and this (errors not being thrown after promise).

I'm developing a project using a Yii2 PHP server-side solution, and Vue frontend solution. The project has several resources (lessons, media, etc) and REST API endpoints on the server-side that all are used the same. My dev work would benefit from me creating a re-usable API client class (in native JS - not anyting Vue related). I created an 'abstract' class that I 'extend' for each resource and use its functions for the CRUD operations.

I'd like to set up some middleware functions that are going to process the response from the API so that will be handled in the same fashion after every request I make so that I don't have to reproduce that processing code in the Vue apps and components that are using those API client classes.

The code is using the native JS fetch() function. I'm using .then() and .catch() in the functions as needed to process responses and control the flow.

My problem is that I have a function to process the API response, and in it I throw an error if I receive a non-200 response. I've implemented .catch() blocks in several places but I always get an error "Uncaught (in promise)" regardless of putting catch() calls everywhere.

When a user starts watching a video, I make an API call to my server to update a status on a user_media record. So, in the Vue component, I use my UserMedia helper class to create() a resource on the server and implement a then() and catch() on that. When there is an error server-side, I expect the catch() to catch that error and handle it. But, I just get the error "Uncaught (in promise)" as if I'm not trying to catch the error at all.

In the code, I am using updateWatchedStatus() in the vimeo video component, that calls the UserMediaApi.create() which calls YiiApiHelper.request() which calls YiiApiHelper.processRestResponse() where the error is thrown. I've tried implementing catch() blocks all over the place but it's never caught.

CLEARLY, I don't understand something about either fetch(), promises, or catching errors. But I can't figure it out. It seems like the only way around this is to have to write a bunch more code to try to compensate. Any help is appreciated. Even if I'm going about this all wrong and should be doing it someway else entirely.

The full code for that can be seen here:

YiiApiHelper.js https://pastebin.com/HJNWYQXg
UserMediaApi.js https://pastebin.com/9u8jkcSP
Vimeo Video Vue Component https://pastebin.com/4dJ1TtdM

For brevity, here's what's important:

Generic API Helper:

const request = function(resource, options){
    return fetch(resource, options)
        .then(response => Promise.all([response, response.json()]));
}
const resourceUrl = function(){
    return this.autoPluralizeResource ?
        this.resourceName+'s' :
        this.resourceName;
}
const create = function(postData, options){
    const url = new URL(this.baseUrl+'/'+this.resourceUrl());
    if(!options){
        options = {};
    }
    options = {
        method: 'POST',
        body: JSON.stringify(postData),
        ...options,
    }

    if(!options.headers){
        options.headers = {};
    }
    options.headers = {
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
        "Content-Type": "application/json",
        ...options.headers
    }
    return this.request(url, options)
        .then(this.processRestResponse);
}
const processRestResponse = function([response, body]){
    if(!response.ok){
        if(response.status ==  422){
            if(Array.isArray(body)){
                let messages = [];
                body.forEach(validationError => {
                    messages.push(validationError.message);
                })
                throw {
                    name: response.status,
                    message: messages.join("\n")
                }
            }
        }
        throw {
            name: response.status,
            message: (body.message) ?
                body.message :
                response.statusText
        }
    }
    return Promise.all([response, body]);
}
export default {
    baseUrl: '',
    resourceName: '',
    autoPluralizeResource: true,

    resourceUrl: resourceUrl,
    request: request,
    create: create,

    processRestResponse: processRestResponse,
    handleErrorResponse: handleErrorResponse
};

UserMedia helper:

import YiiApiHelper from './../../yiivue/YiiApiHelper.js';
export default {
    ...YiiApiHelper,
    baseUrl: window.location.origin+'/media/api/v1',
    resourceName: 'user-media',
    autoPluralizeResource: false
}

VimeoVideo.js:

let updateWatchedStatus = function(watchedStatusId) {
    if(!props.userMedia){
        // --- User has no record for this media, create one
        return UserMediaApi.create({
                media_id: props.media.id,
                user_id: props.userId,
                data: {
                    [Helper.WATCHED_STATUS_KEY]: watchedStatusId
                }
            }).then(([response, body])  => {
                context.emit('userMediaUpdated', {userMedia: body});
                return body;
            }).catch(YiiApiHelper.handleErrorResponse);;
    }

    // --- User has a record, update the watched status in the data
    let data = {
        ...userMedia.value.data,
        [Helper.WATCHED_STATUS_KEY]: watchedStatusId
    }
    return UserMediaApi.update(props.media.id+','+props.userId, {
            data: data
        }).then(([response, body])  => {
            context.emit('userMediaUpdated', {userMedia: body});
            return body;
        }).catch(YiiApiHelper.handleErrorResponse);;
}

Solution

  • Figured out and fixed this a while ago and figured I should come back in case it helps anyone.

    Wrapping the request in a promise, and passing its resolve/reject into promises returned was the solution.

    The code below isn't complete but it's enough to illustrate what had to be done to get this working as intended:

    const request = function(resource, options){
        return new Promise((resolve, reject) => {
            return fetch(resource, options)
                .then(response => {
                    if(
                        options &&
                        options.method == "DELETE" &&
                        response.status == 204
                    ){
                        // --- Yii2 will return a 204 response on successful deletes and
                        // --- running response.json() on that will result in an error
                        // --- "SyntaxError: Unexpected end of JSON input" so we will just
                        // --- avoid that by returning an empty object
                        return Promise.all([response, JSON.stringify("{}"), resolve, reject])
                    }
                    // --- Include resolve/reject for proper error handling by response processing
                    return Promise.all([response, response.json(), resolve, reject])
                }).then(this.processRestResponse)
        });
    }
    const create = function(postData, options){
        const url = new URL(this.baseUrl+'/'+this.resourceUrl());
        if(!options){
            options = {};
        }
        options = {
            method: 'POST',
            body: JSON.stringify(postData),
            ...options,
        }
    
        if(!options.headers){
            options.headers = {};
        }
        options.headers = {
            'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
            "Content-Type": "application/json",
            ...options.headers
        }
        return this.request(url, options);
    }
    const processRestResponse = function([response, body, resolve, reject]){
        // --- If the response is okay pass it all through to the function
        // --- that will be handling a response
        if(response.ok){
            return resolve([response, body]);
        }
        // --- If there are validation errors prepare them in a string
        // --- to throw a user friendly validation error message
        if(
            response.status == 422 &&
            Array.isArray(body)
        ){
            let messages = [];
            body.forEach(validationError => {
                messages.push(validationError.message);
            })
            return reject({
                name: response.status,
                message: messages.join("\n")
            })
        }
        // --- If there is another error just provide the status text
        // --- as a message (Yii provides this)
        return reject({
            name: response.status,
            message: (body.message) ?
                body.message :
                response.statusText
        })
    }
    export default {
        baseUrl: '',
    
        resourceUrl: resourceUrl,
        request: request,
        create: create,
    
        processRestResponse: processRestResponse,
        handleErrorResponse: handleErrorResponse
    };