add error handling for failed requests

This commit is contained in:
hippoz 2023-07-23 05:06:07 +03:00
parent 72c9650f71
commit 00f7ca72a5
Signed by: hippoz
GPG key ID: 56C4E02A85F2FBED
9 changed files with 233 additions and 106 deletions

View file

@ -1,19 +1,19 @@
<script> <script>
import { overlayStore } from "../../stores"; import { methods, remoteSignal, responseOk } from "../../request";
import { methods, remoteSignal } from "../../request"; import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
let communityName = ""; let communityName = "";
let createButtonEnabled = true; let createButtonEnabled = true;
let response;
export let close = () => {}; export let close = () => {};
const create = async () => { const create = async () => {
createButtonEnabled = false; createButtonEnabled = false;
const { ok } = await remoteSignal(methods.createCommunity, communityName); response = await remoteSignal(methods.createCommunity, communityName);
if (!ok) { createButtonEnabled = true;
overlayStore.toast("Couldn't create community"); if (responseOk(response))
} close();
close();
}; };
</script> </script>
@ -21,6 +21,8 @@
<span class="h4" slot="header">Create Community</span> <span class="h4" slot="header">Create Community</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={0} response={response} />
<label class="input-label"> <label class="input-label">
Community Name Community Name
<input class="input full-width" minlength="1" maxlength="32" bind:value={ communityName } /> <input class="input full-width" minlength="1" maxlength="32" bind:value={ communityName } />

View file

@ -1,24 +1,24 @@
<script> <script>
import { overlayStore, OverlayType } from "../../stores"; import { overlayStore, OverlayType } from "../../stores";
import { methods, remoteCall } from "../../request"; import { methods, remoteCall, responseOk } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
let username = ""; let username = "";
let password = ""; let password = "";
let buttonsEnabled = true; let buttonsEnabled = true;
let pendingOtherOpen = false; let pendingOtherOpen = false;
let response;
export let close = () => {}; export let close = () => {};
const create = async () => { const create = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok } = await remoteCall(methods.createUser, username, password); response = await remoteCall(methods.createUser, username, password);
if (ok) { if (responseOk(response)) {
overlayStore.toast("Account created"); overlayStore.toast("Account created");
loginInstead(); loginInstead();
} else { } else {
overlayStore.toast("Couldn't create account");
buttonsEnabled = true; buttonsEnabled = true;
return;
} }
}; };
const loginInstead = () => { const loginInstead = () => {
@ -42,6 +42,9 @@
<span class="h4" slot="header">Create an Account</span> <span class="h4" slot="header">Create an Account</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={0} response={response} />
<label class="input-label"> <label class="input-label">
Username Username
<input class="input full-width" minlength="1" maxlength="32" bind:value={ username } /> <input class="input full-width" minlength="1" maxlength="32" bind:value={ username } />
@ -49,6 +52,7 @@
<div class="separator" /> <div class="separator" />
<RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label"> <label class="input-label">
Password Password
<input class="input full-width" minlength="8" type="password" bind:value={ password } /> <input class="input full-width" minlength="8" type="password" bind:value={ password } />
@ -56,7 +60,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ loginInstead }">Log in instead</button> <button class="button modal-secondary-action" on:click="{ loginInstead }" disabled="{ !buttonsEnabled }">Log in instead</button>
<button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !buttonsEnabled }">Create</button> <button class="button button-accent modal-primary-action" on:click="{ create }" disabled="{ !buttonsEnabled }">Create</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -1,20 +1,20 @@
<script> <script>
import { overlayStore } from "../../stores"; import { methods, remoteSignal, responseOk } from "../../request";
import { methods, remoteSignal } from "../../request"; import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
let channelName = ""; let channelName = "";
let createButtonEnabled = true; let createButtonEnabled = true;
let response;
export let close = () => {}; export let close = () => {};
export let community = null; export let community = null;
const create = async () => { const create = async () => {
createButtonEnabled = false; createButtonEnabled = false;
const { ok } = await remoteSignal(methods.createChannel, channelName, community.id !== -1 ? community.id : null); response = await remoteSignal(methods.createChannel, channelName, community.id !== -1 ? community.id : null);
if (!ok) { createButtonEnabled = true;
overlayStore.toast("Couldn't create channel"); if (responseOk(response))
} close();
close();
}; };
</script> </script>
@ -27,6 +27,8 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={0} response={response} />
<label class="input-label"> <label class="input-label">
Channel Name Channel Name
<input class="input full-width" minlength="1" maxlength="32" bind:value={ channelName } /> <input class="input full-width" minlength="1" maxlength="32" bind:value={ channelName } />

View file

@ -1,27 +1,29 @@
<script> <script>
import { overlayStore } from "../../stores"; import { overlayStore } from "../../stores";
import { methods, remoteSignal } from "../../request"; import { getMessageFromResponse, methods, remoteSignal, responseOk } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
export let channel; export let channel;
let channelName = channel.name; let channelName = channel.name;
let buttonsEnabled = true; let buttonsEnabled = true;
let response;
export let close = () => {}; export let close = () => {};
const save = async () => { const save = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok } = await remoteSignal(methods.updateChannelName, channel.id, channelName); response = await remoteSignal(methods.updateChannelName, channel.id, channelName);
if (!ok) { buttonsEnabled = true;
overlayStore.toast("Couldn't edit channel"); if (responseOk(response)) {
close();
} }
close();
}; };
const deleteChannel = async () => { const deleteChannel = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok } = await remoteSignal(methods.deleteChannel, channel.id); const res = await remoteSignal(methods.deleteChannel, channel.id);
if (!ok) { if (!responseOk(res)) {
overlayStore.toast("Couldn't delete channel"); overlayStore.toast(`Couldn't delete channel: ${getMessageFromResponse(res)}`);
} }
close(); close();
}; };
@ -37,8 +39,10 @@
<span class="h4" slot="header">Edit Channel</span> <span class="h4" slot="header">Edit Channel</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label"> <label class="input-label">
Channel Name <span>Channel Name</span>
<input class="input full-width" minlength="1" maxlength="32" bind:value={ channelName } /> <input class="input full-width" minlength="1" maxlength="32" bind:value={ channelName } />
</label> </label>
</svelte:fragment> </svelte:fragment>

View file

@ -1,27 +1,28 @@
<script> <script>
import { overlayStore } from "../../stores"; import { overlayStore } from "../../stores";
import { methods, remoteSignal } from "../../request"; import { getMessageFromResponse, methods, remoteSignal, responseOk } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
export let message; export let message;
let messageContent = message.content; let messageContent = message.content;
let buttonsEnabled = true; let buttonsEnabled = true;
let response;
export let close = () => {}; export let close = () => {};
const save = async () => { const save = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok } = await remoteSignal(methods.updateMessageContent, message.id, messageContent); response = await remoteSignal(methods.updateMessageContent, message.id, messageContent);
if (!ok) { buttonsEnabled = true;
overlayStore.toast("Couldn't edit message"); if (responseOk(response))
} close();
close();
}; };
const deleteMessage = async () => { const deleteMessage = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok } = await remoteSignal(methods.deleteMessage, message.id); const res = await remoteSignal(methods.deleteMessage, message.id);
if (!ok) { if (!responseOk(res)) {
overlayStore.toast("Couldn't delete message"); overlayStore.toast(`Couldn't delete message: ${getMessageFromResponse(res)}`);
} }
close(); close();
}; };
@ -37,6 +38,8 @@
<span class="h4" slot="header">Edit Message</span> <span class="h4" slot="header">Edit Message</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label"> <label class="input-label">
Content Content
<input class="input full-width" minlength="1" bind:value={ messageContent } /> <input class="input full-width" minlength="1" bind:value={ messageContent } />

View file

@ -1,29 +1,25 @@
<script> <script>
import { overlayStore, OverlayType } from "../../stores"; import { overlayStore, OverlayType } from "../../stores";
import { remoteCall } from "../../request"; import { remoteCall, responseOk } from "../../request";
import { authWithToken } from "../../auth"; import { authWithToken } from "../../auth";
import { methods } from "../../request"; import { methods } from "../../request";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import RpcErrorDisplay from "../rpc/RpcErrorDisplay.svelte";
let username = ""; let username = "";
let password = ""; let password = "";
let buttonsEnabled = true; let buttonsEnabled = true;
let pendingOtherOpen = false; let pendingOtherOpen = false;
let response;
export let close = () => {}; export let close = () => {};
const login = async () => { const login = async () => {
buttonsEnabled = false; buttonsEnabled = false;
const { ok, json } = await remoteCall(methods.loginUser, username, password); response = await remoteCall(methods.loginUser, username, password);
if (ok && json && json.token) { if (responseOk(response) && response.data && response.data.token) {
authWithToken(json.token, true); authWithToken(response.data.token, true);
} else { } else {
if (json && json.code && json.code === 6002) { // 6002 is the code for bad login
overlayStore.toast("Invalid username or password");
} else {
overlayStore.toast("Couldn't log in");
}
buttonsEnabled = true; buttonsEnabled = true;
return;
} }
}; };
const createAccountInstead = () => { const createAccountInstead = () => {
@ -47,6 +43,9 @@
<span class="h4" slot="header">Welcome back!</span> <span class="h4" slot="header">Welcome back!</span>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
<RpcErrorDisplay response={response} />
<RpcErrorDisplay validationIndex={0} response={response} />
<label class="input-label"> <label class="input-label">
Username Username
<input class="input full-width" minlength="1" maxlength="32" bind:value={ username } /> <input class="input full-width" minlength="1" maxlength="32" bind:value={ username } />
@ -54,6 +53,7 @@
<div class="separator" /> <div class="separator" />
<RpcErrorDisplay validationIndex={1} response={response} />
<label class="input-label"> <label class="input-label">
Password Password
<input class="input full-width" minlength="8" type="password" bind:value={ password } /> <input class="input full-width" minlength="8" type="password" bind:value={ password } />
@ -61,7 +61,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<button class="button modal-secondary-action" on:click="{ createAccountInstead }">Create an account instead</button> <button class="button modal-secondary-action" on:click="{ createAccountInstead }" disabled="{ !buttonsEnabled }">Create an account instead</button>
<button class="button button-accent modal-primary-action" on:click="{ login }" disabled="{ !buttonsEnabled }">Log In</button> <button class="button button-accent modal-primary-action" on:click="{ login }" disabled="{ !buttonsEnabled }">Log In</button>
</svelte:fragment> </svelte:fragment>
</Modal> </Modal>

View file

@ -0,0 +1,41 @@
<script>
import { getErrorFromResponse } from "../../request";
export let response = null;
export let validationIndex = -1;
let message = null;
$: {
const error = getErrorFromResponse(response);
if (error) {
if (validationIndex >= 0) {
if (error.validationErrors) {
const found = error.validationErrors.find(e => e.index === validationIndex);
if (found) {
message = found.msg;
}
} else {
message = null;
}
} else {
if (error.validationErrors) {
message = null;
} else {
message = error.message;
}
}
}
}
</script>
<style>
span {
color: var(--red-1);
display: block;
}
</style>
{#if message}
<span>{ message }</span>
{/if}

View file

@ -29,34 +29,71 @@ export const methods = {
getCommunityChannels: withCacheable(method(405, true)), getCommunityChannels: withCacheable(method(405, true)),
}; };
export function compatibleFetch(endpoint, options) { export const RPCError = {
if (window.fetch && typeof window.fetch === "function") { BAD_REQUEST: { code: 6000, message: "Bad request" },
return fetch(endpoint, options); RPC_VALIDATION_ERROR: { code: 6001, message: "We couldn't validate this request" },
} else { BAD_LOGIN_CREDENTIALS: { code: 6002, message: "Incorrect login credentials" },
return new Promise((resolve, reject) => { BAD_AUTH: { code: 6003, message: "You're not authenticated" },
const req = new XMLHttpRequest(); NOT_FOUND: { code: 6004, message: "Not found" },
req.addEventListener("load", () => { FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "You don't have the required permissions to perform this action" },
resolve({ BAD_REQUEST_KEY: { code: 6006, message: "This request requires a special password, however, the password you provided was incorrect" },
status: req.status, GOT_NO_DATABASE_DATA: { code: 7001, message: "Sorry, we couldn't process this request (server expected data from database, however got none)" },
ok: [200, 201, 204].includes(req.status), FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" },
json() { INTERNAL_ERROR: { code: 7003, message: "Sorry, we couldn't process this request (internal server error)" },
return JSON.parse(req.responseText); };
}
});
});
req.addEventListener("error", (e) => {
reject(e);
});
req.open(options.method || "GET", endpoint); export const RequestStatus = {
if (options.headers) { OK: 0,
for (const [header, value] of Object.entries(options.headers)) { NETWORK_EXCEPTION: 1,
req.setRequestHeader(header, value); JSON_EXCEPTION: 2,
} FAILURE_STATUS: 3,
} RPC_ERROR: 4,
req.send(options.body); INVARIANT_RPC_RESPONSE_COUNT: 5,
}); };
export const RequestStatusToMessage = {
[RequestStatus.OK]: "",
[RequestStatus.NETWORK_EXCEPTION]: "We couldn't reach the server right now",
[RequestStatus.JSON_EXCEPTION]: "We couldn't process this request right now (server gave an invalid response, failed to parse as JSON)",
[RequestStatus.FAILURE_STATUS]: "We couldn't process this request right now (server gave a failure status code)",
[RequestStatus.RPC_ERROR]: "We couldn't process this request right now (RPC error)",
[RequestStatus.INVARIANT_RPC_RESPONSE_COUNT]: "We couldn't process this request right now (invalid RPC response)",
};
export function getErrorFromResponse(response) {
if (!response) return;
if (response.status === RequestStatus.OK) return;
console.log(response);
let message = RequestStatusToMessage[response.status];
if (!message) message = "Something went wrong (unknown request error)";
if (response.status === RequestStatus.RPC_ERROR) {
let rpcErrorMessage = Object.values(RPCError).find(({ code }) => code === response.data.code);
if (rpcErrorMessage) {
rpcErrorMessage = rpcErrorMessage.message;
} else {
rpcErrorMessage = "Something went wrong (unknown RPC error)";
}
if (response.data.code === RPCError.RPC_VALIDATION_ERROR.code) {
return { message: rpcErrorMessage, validationErrors: response.data.errors };
}
return { message: rpcErrorMessage };
} }
return { message };
}
export function getMessageFromResponse(response) {
return getErrorFromResponse(response).message || "Something went wrong";
}
export function responseOk(response) {
if (response.status !== RequestStatus.OK) return false;
return true;
} }
export default function doRequest(method, endpoint, auth=true, body=null) { export default function doRequest(method, endpoint, auth=true, body=null) {
@ -83,31 +120,45 @@ export default function doRequest(method, endpoint, auth=true, body=null) {
} }
} }
let res;
try { try {
const res = await compatibleFetch(endpoint, options); res = await fetch(endpoint, options);
const json = res.status === 204 ? {} : await res.json(); } catch(o_O) {
return resolve({ return resolve({
json, status: RequestStatus.NETWORK_EXCEPTION
ok: res.ok,
});
} catch (e) {
return resolve({
json: null,
ok: false,
}); });
} }
let json;
try {
json = res.status === 204 ? {} : await res.json();
} catch(o_O) {
return resolve({
status: RequestStatus.JSON_EXCEPTION
});
}
resolve({
data: json,
status: res.ok ? RequestStatus.OK : RequestStatus.FAILURE_STATUS
});
}); });
} }
export async function remoteCall({methodId, requiresAuthentication, cacheable, _isSignal=false}, ...args) { export async function remoteCall({methodId, requiresAuthentication, cacheable, _isSignal=false}, ...args) {
const calls = [[methodId, ...args]]; const calls = [[methodId, ...args]];
if (requiresAuthentication && gateway.authenticated && !cacheable) { if (requiresAuthentication && gateway.authenticated && !cacheable) {
const replies = await gateway.sendRPCRequest(calls, _isSignal); const replies = await gateway.sendRPCRequest(calls, _isSignal);
const ok = Array.isArray(replies) && !(replies[0] && replies[0].code); if (!Array.isArray(replies) || replies.length !== 1) {
return { status: RequestStatus.INVARIANT_RPC_RESPONSE_COUNT };
}
const reply = replies[0];
return { return {
json: ok ? replies[0] : null, data: reply,
ok status: reply && reply.code ? RequestStatus.RPC_ERROR : RequestStatus.OK
}; };
} }
@ -117,9 +168,21 @@ export async function remoteCall({methodId, requiresAuthentication, cacheable, _
} else { } else {
response = await doRequest("POST", apiRoute("rpc"), requiresAuthentication, calls); response = await doRequest("POST", apiRoute("rpc"), requiresAuthentication, calls);
} }
response.ok = response.ok && Array.isArray(response.json) && !(response.json[0] && response.json[0].code);
response.json = response.ok ? response.json[0] : null; if (response.status !== RequestStatus.OK) {
return response; return { status: response.status };
}
if (!Array.isArray(response.data) || response.data.length !== 1) {
return { status: RequestStatus.INVARIANT_RPC_RESPONSE_COUNT };
}
const reply = response.data[0];
return {
data: reply,
status: reply && reply.code ? RequestStatus.RPC_ERROR : RequestStatus.OK
};
} }
export async function remoteSignal(method, ...args) { export async function remoteSignal(method, ...args) {
@ -131,14 +194,22 @@ export async function remoteSignal(method, ...args) {
export async function remoteBlobUpload({methodId, requiresAuthentication, _isSignal=false}, blob) { export async function remoteBlobUpload({methodId, requiresAuthentication, _isSignal=false}, blob) {
const calls = [[methodId, [0, blob.size]]]; const calls = [[methodId, [0, blob.size]]];
if (requiresAuthentication && gateway.authenticated) { if (requiresAuthentication && gateway.authenticated) {
const replies = await gateway.sendRPCRequest(calls, _isSignal, blob); const replies = await gateway.sendRPCRequest(calls, _isSignal, blob);
const ok = Array.isArray(replies) && !(replies[0] && replies[0].code); if (!Array.isArray(replies) || replies.length !== 1) {
return { status: RequestStatus.INVARIANT_RPC_RESPONSE_COUNT };
}
const reply = replies[0];
return { return {
json: ok ? replies[0] : null, data: reply,
ok status: reply && reply.code ? RequestStatus.RPC_ERROR : RequestStatus.OK
}; };
} else { } else {
return { json: null, ok: false }; return {
status: RequestStatus.NETWORK_EXCEPTION
};
} }
} }

View file

@ -1,6 +1,6 @@
import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway"; import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway";
import logger from "./logging"; import logger from "./logging";
import { methods, remoteCall, remoteSignal } from "./request"; import { getMessageFromResponse, methods, remoteCall, remoteSignal, responseOk } from "./request";
import { getItem, setItem } from "./storage"; import { getItem, setItem } from "./storage";
const storeLog = logger("Store"); const storeLog = logger("Store");
@ -335,17 +335,17 @@ class MessageStore extends Store {
const oldestMessage = this.value[0]; const oldestMessage = this.value[0];
const res = await remoteCall(methods.getChannelMessages, this.channelId, null, oldestMessage ? oldestMessage.id : null); const res = await remoteCall(methods.getChannelMessages, this.channelId, null, oldestMessage ? oldestMessage.id : null);
if (res.ok) { if (responseOk(res)) {
if (res.json.length < 1) if (res.data.length < 1)
return; return;
if (beforeCommitToStore) if (beforeCommitToStore)
beforeCommitToStore(res.json); beforeCommitToStore(res.data);
res.json.reverse(); res.data.reverse();
this.value = res.json.concat(this.value); this.value = res.data.concat(this.value);
this._recomputeMessages(); this._recomputeMessages();
this.updated(); this.updated();
} else { } else {
overlayStore.toast("Messages failed to load"); overlayStore.toast(`Messages failed to load: ${getMessageFromResponse(res)}`);
} }
} }
@ -877,11 +877,11 @@ export const sendMessageAction = createAction("sendMessageAction", async ({chann
const res = await remoteSignal(methods.createChannelMessage, channelId, content, optimisticMessageId, null); const res = await remoteSignal(methods.createChannelMessage, channelId, content, optimisticMessageId, null);
if (!res.ok) { if (!responseOk(res)) {
messagesStoreForChannel.deleteMessage({ messagesStoreForChannel.deleteMessage({
id: optimisticMessageId id: optimisticMessageId
}); });
overlayStore.toast("Couldn't send message"); overlayStore.toast(`Couldn't send message: ${getMessageFromResponse(res)}`);
} }
}); });