Skip to content

Commit

Permalink
Handle API errors on Send page
Browse files Browse the repository at this point in the history
These generally come from bad input, but previously the UI just got
stuck and didn't explain anything - now the request resets so you can
try again, and an error alert appears.
  • Loading branch information
pimterry committed May 2, 2024
1 parent bbbb188 commit 2d34dc6
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 105 deletions.
9 changes: 8 additions & 1 deletion src/components/send/send-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { styled } from '../../styles';
import { useHotkeys } from '../../util/ui';
import { WithInjected } from '../../types';

import { ApiError } from '../../services/server-api-types';
import { SendStore } from '../../model/send/send-store';

import { ContainerSizedEditor } from '../editor/base-editor';
Expand Down Expand Up @@ -66,7 +67,13 @@ class SendPage extends React.Component<{
selectedRequest
} = this.props.sendStore;

sendRequest(selectedRequest);
sendRequest(selectedRequest).catch(e => {
console.log(e);
const errorMessage = (e instanceof ApiError && e.apiErrorMessage)
? e.apiErrorMessage
: e.message ?? e;
alert(errorMessage);
});
};

private showRequestOnViewPage = () => {
Expand Down
202 changes: 106 additions & 96 deletions src/model/send/send-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,110 +135,120 @@ export class SendStore {
const requestInput = sendRequest.request;
const pendingRequestDeferred = getObservableDeferred();
const abortController = new AbortController();
runInAction(() => {
sendRequest.sentExchange = undefined;

sendRequest.pendingSend = {
promise: pendingRequestDeferred.promise,
abort: () => abortController.abort()
};

const clearPending = action(() => { sendRequest.pendingSend = undefined; });
sendRequest.pendingSend.promise.then(clearPending, clearPending);
});
try {
runInAction(() => {
sendRequest.sentExchange = undefined;

const exchangeId = uuid();
sendRequest.pendingSend = {
promise: pendingRequestDeferred.promise,
abort: () => abortController.abort()
};

const passthroughOptions = this.rulesStore.activePassthroughOptions;

const url = new URL(requestInput.url);
const effectivePort = getEffectivePort(url);
const hostWithPort = `${url.hostname}:${effectivePort}`;
const clientCertificate = passthroughOptions.clientCertificateHostMap?.[hostWithPort] ||
passthroughOptions.clientCertificateHostMap?.[url.hostname!] ||
undefined;

const requestOptions = {
ignoreHostHttpsErrors: passthroughOptions.ignoreHostHttpsErrors,
trustAdditionalCAs: this.rulesStore.additionalCaCertificates.map((cert) =>
({ cert: cert.rawPEM })
),
clientCertificate,
proxyConfig: getProxyConfig(this.rulesStore.proxyConfig),
lookupOptions: passthroughOptions.lookupOptions
};
const clearPending = action(() => { sendRequest.pendingSend = undefined; });
sendRequest.pendingSend.promise.then(clearPending, clearPending);
});

const encodedBody = await requestInput.rawBody.encodingBestEffortPromise;
const exchangeId = uuid();

const passthroughOptions = this.rulesStore.activePassthroughOptions;

const url = new URL(requestInput.url);
const effectivePort = getEffectivePort(url);
const hostWithPort = `${url.hostname}:${effectivePort}`;
const clientCertificate = passthroughOptions.clientCertificateHostMap?.[hostWithPort] ||
passthroughOptions.clientCertificateHostMap?.[url.hostname!] ||
undefined;

const requestOptions = {
ignoreHostHttpsErrors: passthroughOptions.ignoreHostHttpsErrors,
trustAdditionalCAs: this.rulesStore.additionalCaCertificates.map((cert) =>
({ cert: cert.rawPEM })
),
clientCertificate,
proxyConfig: getProxyConfig(this.rulesStore.proxyConfig),
lookupOptions: passthroughOptions.lookupOptions
};

const responseStream = await ServerApi.sendRequest(
{
url: requestInput.url,
const encodedBody = await requestInput.rawBody.encodingBestEffortPromise;

const responseStream = await ServerApi.sendRequest(
{
url: requestInput.url,
method: requestInput.method,
headers: requestInput.headers,
rawBody: encodedBody
},
requestOptions,
abortController.signal
);

const exchange = this.eventStore.recordSentRequest({
id: exchangeId,
httpVersion: '1.1',
matchedRuleId: false,
method: requestInput.method,
headers: requestInput.headers,
rawBody: encodedBody
},
requestOptions,
abortController.signal
);

const exchange = this.eventStore.recordSentRequest({
id: exchangeId,
httpVersion: '1.1',
matchedRuleId: false,
method: requestInput.method,
url: requestInput.url,
protocol: url.protocol.slice(0, -1),
path: url.pathname,
hostname: url.hostname,
headers: rawHeadersToHeaders(requestInput.headers),
rawHeaders: _.cloneDeep(requestInput.headers),
body: { buffer: encodedBody },
timingEvents: {
startTime: Date.now()
} as TimingEvents,
tags: ['httptoolkit:manually-sent-request']
});
url: requestInput.url,
protocol: url.protocol.slice(0, -1),
path: url.pathname,
hostname: url.hostname,
headers: rawHeadersToHeaders(requestInput.headers),
rawHeaders: _.cloneDeep(requestInput.headers),
body: { buffer: encodedBody },
timingEvents: {
startTime: Date.now()
} as TimingEvents,
tags: ['httptoolkit:manually-sent-request']
});

// Keep the exchange up to date as response data arrives:
trackResponseEvents(responseStream, exchange)
.catch(action((error: ErrorLike & { timingEvents?: TimingEvents }) => {
if (error.name === 'AbortError' && abortController.signal.aborted) {
const startTime = exchange.timingEvents.startTime!; // Always set in Send case (just above)
// Make a guess at an aborted timestamp, since this error won't give us one automatically:
const durationBeforeAbort = Date.now() - startTime;
const startTimestamp = exchange.timingEvents.startTimestamp ?? startTime;
const abortedTimestamp = startTimestamp + durationBeforeAbort;

exchange.markAborted({
id: exchange.id,
error: {
message: 'Request cancelled'
},
timingEvents: {
startTimestamp,
abortedTimestamp,
...exchange.timingEvents,
...error.timingEvents
} as TimingEvents,
tags: ['client-error:ECONNABORTED']
});
} else {
exchange.markAborted({
id: exchange.id,
error: error,
timingEvents: {
...exchange.timingEvents as TimingEvents,
...error.timingEvents
},
tags: error.code ? [`passthrough-error:${error.code}`] : []
});
}
}))
.then(() => pendingRequestDeferred.resolve());
// Keep the exchange up to date as response data arrives:
trackResponseEvents(responseStream, exchange)
.catch(action((error: ErrorLike & { timingEvents?: TimingEvents }) => {
if (error.name === 'AbortError' && abortController.signal.aborted) {
const startTime = exchange.timingEvents.startTime!; // Always set in Send case (just above)
// Make a guess at an aborted timestamp, since this error won't give us one automatically:
const durationBeforeAbort = Date.now() - startTime;
const startTimestamp = exchange.timingEvents.startTimestamp ?? startTime;
const abortedTimestamp = startTimestamp + durationBeforeAbort;

exchange.markAborted({
id: exchange.id,
error: {
message: 'Request cancelled'
},
timingEvents: {
startTimestamp,
abortedTimestamp,
...exchange.timingEvents,
...error.timingEvents
} as TimingEvents,
tags: ['client-error:ECONNABORTED']
});
} else {
exchange.markAborted({
id: exchange.id,
error: error,
timingEvents: {
...exchange.timingEvents as TimingEvents,
...error.timingEvents
},
tags: error.code ? [`passthrough-error:${error.code}`] : []
});
}
}))
.then(() => pendingRequestDeferred.resolve());

runInAction(() => {
sendRequest.sentExchange = exchange;
});
runInAction(() => {
sendRequest.sentExchange = exchange;
});
} catch (e: any) {
pendingRequestDeferred.reject(e);
runInAction(() => {
sendRequest.pendingSend = undefined;
sendRequest.sentExchange = undefined;
});
throw e;
}
}

}
Expand Down
3 changes: 2 additions & 1 deletion src/services/server-api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly operationName: string,
readonly errorCode?: string | number
readonly errorCode?: string | number,
public apiErrorMessage?: string
) {
super(`API error during ${operationName}: ${message}`);
}
Expand Down
15 changes: 8 additions & 7 deletions src/services/server-rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export class RestApiClient {
: undefined,
signal: options?.abortSignal
}).catch((e) => {
throw new ApiError(`fetch failed with '${e.message ?? e}'`, operationName);
const errorMessage = e.message ?? e;
throw new ApiError(`fetch failed with '${errorMessage}'`, operationName);
});

if (!response.ok) {
Expand All @@ -71,16 +72,16 @@ export class RestApiClient {

console.error(response.status, errorBody);

const errorMessage = errorBody?.error?.message ?? '[unknown]';
const errorCode = errorBody?.error?.code;

throw new ApiError(
`unexpected ${response.status} ${response.statusText} - ${
errorBody?.error?.code
? `${errorBody?.error?.code} -`
: ''
}${
errorBody?.error?.message ?? '[unknown]'
errorCode ? `${errorCode} -` : ''
}`,
operationName,
response.status
response.status,
errorMessage
);
}

Expand Down

0 comments on commit 2d34dc6

Please sign in to comment.