From a018fee2400a2c1cd54058dbd25d3bf5ea5be482 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 11 Mar 2024 20:55:56 +0100 Subject: [PATCH] Add full explanations & improve UI for Send failures --- src/components/send/response-pane.tsx | 38 +++- src/components/send/sent-response-error.tsx | 102 ++++++++++ src/components/send/sent-response-status.tsx | 28 ++- .../view/http/http-details-pane.tsx | 3 +- .../view/http/http-error-header.tsx | 174 ++---------------- .../view/http/http-performance-card.tsx | 6 +- src/model/http/error-types.ts | 163 ++++++++++++++++ src/util/index.ts | 4 + 8 files changed, 339 insertions(+), 179 deletions(-) create mode 100644 src/components/send/sent-response-error.tsx create mode 100644 src/model/http/error-types.ts diff --git a/src/components/send/response-pane.tsx b/src/components/send/response-pane.tsx index 69956059..6b1b4d64 100644 --- a/src/components/send/response-pane.tsx +++ b/src/components/send/response-pane.tsx @@ -3,20 +3,21 @@ import { inject, observer } from "mobx-react"; import * as portals from 'react-reverse-portal'; import { HttpExchange } from "../../types"; +import { logError } from "../../errors"; import { UiStore } from '../../model/ui/ui-store'; import { AccountStore } from '../../model/account/account-store'; -import { SuccessfulExchange } from "../../model/http/exchange"; +import { CompletedExchange, SuccessfulExchange } from "../../model/http/exchange"; import { RequestInput } from "../../model/send/send-request-model"; +import { tagsToErrorType } from "../../model/http/error-types"; import { ContainerSizedEditor } from '../editor/base-editor'; -import { HttpAbortedResponseCard } from '../view/http/http-aborted-card'; import { SendCardContainer } from './send-card-section'; -import { PendingResponseStatusSection, ResponseStatusSection } from './sent-response-status'; +import { FailedResponseStatusSection, PendingResponseStatusSection, ResponseStatusSection } from './sent-response-status'; import { PendingResponseHeaderSection, SentResponseHeaderSection } from './sent-response-headers'; import { SentResponseBodyCard } from './sent-response-body'; - +import { SentResponseError } from './sent-response-error'; @inject('uiStore') @inject('accountStore') @observer @@ -44,7 +45,7 @@ export class ResponsePane extends React.Component<{ exchange.isSuccessfulExchange() ? this.renderSuccessfulResponse(exchange) : exchange.isCompletedExchange() - ? this.renderAbortedResponse(exchange) + ? this.renderFailedResponse(exchange) : this.renderInProgressResponse() } ; @@ -75,11 +76,28 @@ export class ResponsePane extends React.Component<{ } - renderAbortedResponse(exchange: HttpExchange) { - return ; + renderFailedResponse(exchange: CompletedExchange) { + const { uiStore } = this.props; + + const errorType = tagsToErrorType(exchange.tags); + + if (!errorType) { + logError(`Sent response failed with no error tags: ${ + JSON.stringify(exchange.tags) + } (${exchange.abortMessage})`); + } + + return <> + + + ; } renderInProgressResponse() { diff --git a/src/components/send/sent-response-error.tsx b/src/components/send/sent-response-error.tsx new file mode 100644 index 00000000..5d985986 --- /dev/null +++ b/src/components/send/sent-response-error.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { styled } from '../../styles'; +import { WarningIcon } from '../../icons'; +import { unreachableCheck } from '../../util/error'; +import { logError } from '../../errors'; + +import { + ErrorType, + wasNotForwarded, + wasServerIssue +} from '../../model/http/error-types'; + +import { SendCardSection } from './send-card-section'; +import { ContentMonoValue } from '../common/text-content'; + +const ExplanationBlock = styled.p` + margin-bottom: 10px; + line-height: 1.3; +`; + +const FailureBlock = styled(ExplanationBlock)` + font-weight: bold; +`; + +export const SentResponseError = (props: { + errorType: ErrorType, + errorMessage: string | undefined +}) => { + const { errorType, errorMessage } = props; + + if ( + !wasNotForwarded(errorType) && + !wasServerIssue(errorType) + ) { + logError(`Unexpected Send error type: ${errorType}`); + } + + return +
+

+ Request Failure +

+
+ + { + wasNotForwarded(errorType) + ? 'This request was not sent successfully' + : wasServerIssue(errorType) + ? 'This response was not received successfully' + : `The request failed because of an unexpected error: ${errorType}` + } + + { + wasNotForwarded(errorType) + ? + The upstream server { + errorType === 'wrong-host' + ? 'responded with an HTTPS certificate for the wrong hostname' + : errorType === 'expired' + ? 'has an expired HTTPS certificate' + : errorType === 'not-yet-valid' + ? 'has an HTTPS certificate with a start date in the future' + : errorType === 'untrusted' + ? 'has an untrusted HTTPS certificate' + : errorType === 'tls-error' + ? 'failed to complete a TLS handshake' + : errorType === 'host-unreachable' + ? 'was not reachable on your network connection' + : errorType === 'host-not-found' || errorType === 'dns-error' + ? 'hostname could be not found' + : errorType === 'connection-refused' + ? 'refused the connection' + : unreachableCheck(errorType) + }, so HTTP Toolkit did not send the request. + + : wasServerIssue(errorType) + ? + The upstream request failed because { + errorType === 'connection-reset' + ? 'the connection to the server was reset' + : errorType === 'server-unparseable' + ? 'the response from the server was unparseable' + : errorType === 'server-timeout' + ? 'of a timeout waiting for a response from the server' + : unreachableCheck(errorType) + }. + + : + It's not clear what's gone wrong here, but for some reason HTTP Toolkit + couldn't successfully and/or securely complete this request. This might be an + intermittent issue, and may be resolved by retrying the request. + + } + { !!errorMessage && + { errorMessage } + } +
+} \ No newline at end of file diff --git a/src/components/send/sent-response-status.tsx b/src/components/send/sent-response-status.tsx index f0fdd677..0da5a5b7 100644 --- a/src/components/send/sent-response-status.tsx +++ b/src/components/send/sent-response-status.tsx @@ -1,11 +1,13 @@ +import * as _ from 'lodash'; import * as React from 'react'; import { Theme, styled } from '../../styles'; +import { getReadableSize } from '../../util/buffer'; import { getStatusColor } from '../../model/events/categorization'; import { getStatusMessage } from '../../model/http/http-docs'; -import { getReadableSize } from '../../util/buffer'; -import { SuccessfulExchange } from '../../model/http/exchange'; +import { CompletedExchange, SuccessfulExchange } from '../../model/http/exchange'; +import { ErrorType } from '../../model/http/error-types'; import { SendCardSection } from './send-card-section'; import { Pill } from '../common/pill'; @@ -69,4 +71,26 @@ export const PendingResponseStatusSection = (props: { ; +} + +export const FailedResponseStatusSection = (props: { + exchange: CompletedExchange, + errorType: ErrorType + theme: Theme +}) => { + return +
+ + Failed: { _.startCase(props.errorType) } + + +
+
; } \ No newline at end of file diff --git a/src/components/view/http/http-details-pane.tsx b/src/components/view/http/http-details-pane.tsx index 4872daa3..0f93c3b5 100644 --- a/src/components/view/http/http-details-pane.tsx +++ b/src/components/view/http/http-details-pane.tsx @@ -16,6 +16,7 @@ import { buildRuleFromRequest } from '../../../model/rules/rule-creation'; import { findItem } from '../../../model/rules/rules-structure'; import { HtkMockRule, getRulePartKey } from '../../../model/rules/rules'; import { WebSocketStream } from '../../../model/websockets/websocket-stream'; +import { tagsToErrorType } from '../../../model/http/error-types'; import { PaneOuterContainer, PaneScrollContainer } from '../view-details-pane'; import { StreamMessageListCard } from '../stream-message-list-card'; @@ -29,7 +30,7 @@ import { HttpAbortedResponseCard } from './http-aborted-card'; import { HttpPerformanceCard } from './http-performance-card'; import { HttpExportCard } from './http-export-card'; import { SelfSizedEditor } from '../../editor/base-editor'; -import { HttpErrorHeader, tagsToErrorType } from './http-error-header'; +import { HttpErrorHeader } from './http-error-header'; import { HttpDetailsFooter } from './http-details-footer'; import { HttpRequestBreakpointHeader, HttpResponseBreakpointHeader } from './http-breakpoint-header'; import { HttpBreakpointRequestCard } from './http-breakpoint-request-card'; diff --git a/src/components/view/http/http-error-header.tsx b/src/components/view/http/http-error-header.tsx index a4f3b46b..ce80a5a3 100644 --- a/src/components/view/http/http-error-header.tsx +++ b/src/components/view/http/http-error-header.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import * as semver from 'semver'; import { WarningIcon } from '../../../icons'; -import { logError } from '../../../errors'; import { desktopVersion, @@ -11,6 +10,18 @@ import { } from '../../../services/service-versions'; import { unreachableCheck } from '../../../util/error'; + +import { + ErrorType, + isClientBug, + isInitialRequestError, + isMockable, + isWhitelistable, + wasNotForwarded, + wasResponseIssue, + wasTimeout +} from '../../../model/http/error-types'; + import { clickOnEnter } from '../../component-utils'; import { HeaderCard, @@ -18,165 +29,6 @@ import { HeaderButton } from '../header-card'; -type ErrorType = - | 'untrusted' - | 'expired' - | 'not-yet-valid' - | 'wrong-host' - | 'tls-error' - | 'host-not-found' - | 'host-unreachable' - | 'dns-error' - | 'connection-refused' - | 'connection-reset' - | 'client-abort' - | 'server-timeout' - | 'client-timeout' - | 'invalid-http-version' - | 'invalid-method' - | 'client-unparseable-url' - | 'client-unparseable' - | 'server-unparseable' - | 'header-overflow' - | 'invalid-headers' - | 'unknown'; - -export function tagsToErrorType(tags: string[]): ErrorType | undefined { - if ( - tags.includes("passthrough-error:SELF_SIGNED_CERT_IN_CHAIN") || - tags.includes("passthrough-error:DEPTH_ZERO_SELF_SIGNED_CERT") || - tags.includes("passthrough-error:UNABLE_TO_VERIFY_LEAF_SIGNATURE") || - tags.includes("passthrough-error:UNABLE_TO_GET_ISSUER_CERT_LOCALLY") - ) { - return 'untrusted'; - } - - if (tags.includes("passthrough-error:CERT_HAS_EXPIRED")) return 'expired'; - if (tags.includes("passthrough-error:CERT_NOT_YET_VALID")) return 'not-yet-valid'; - if (tags.includes("passthrough-error:ERR_TLS_CERT_ALTNAME_INVALID")) return 'wrong-host'; - - if ( - tags.filter(t => t.startsWith("passthrough-tls-error:")).length > 0 || - tags.includes("passthrough-error:EPROTO") || - tags.includes("passthrough-error:ERR_SSL_WRONG_VERSION_NUMBER") || - tags.includes("passthrough-error:ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC") || - tags.includes("passthrough-error:ERR_SSL_CIPHER_OPERATION_FAILED") || - tags.includes("passthrough-error:ERR_SSL_BAD_RECORD_TYPE") || - tags.includes("passthrough-error:ERR_SSL_INTERNAL_ERROR") - ) { - return 'tls-error'; - } - - if (tags.includes("passthrough-error:ENOTFOUND")) return 'host-not-found'; - if ( - tags.includes("passthrough-error:EHOSTUNREACH") || // No known route to this host - tags.includes("passthrough-error:ENETUNREACH") // Whole network is unreachable - ) return 'host-unreachable'; - if (tags.includes("passthrough-error:EAI_AGAIN")) return 'dns-error'; - if (tags.includes("passthrough-error:ECONNREFUSED")) return 'connection-refused'; - if (tags.includes("passthrough-error:ECONNRESET")) return 'connection-reset'; - if (tags.includes("passthrough-error:ETIMEDOUT")) return 'server-timeout'; - if ( - tags.includes("passthrough-error:HPE_INVALID_CONSTANT") || - tags.includes("passthrough-error:ERR_INVALID_HTTP_TOKEN") || - tags.includes("passthrough-error:ERR_HTTP_INVALID_STATUS_CODE") || - tags.includes("passthrough-error:ERR_INVALID_CHAR") - ) { - return 'server-unparseable'; - } - - if (tags.includes("http-2") || tags.includes("client-error:HPE_INVALID_VERSION")) { - return 'invalid-http-version'; - } - - if (tags.includes("client-error:HPE_INVALID_METHOD")) return 'invalid-method'; // QWE / HTTP/1.1 - if (tags.includes("client-error:HPE_INVALID_URL")) return 'client-unparseable-url'; // http:// - if ( - tags.includes("client-error:HPE_INVALID_CONSTANT") || // GET / HTTQ <- incorrect constant char - tags.includes("client-error:HPE_INVALID_EOF_STATE") // Unexpected 0-length packet in parser - ) return 'client-unparseable'; // ABC/1.1 - if (tags.includes("client-error:HPE_HEADER_OVERFLOW")) return 'header-overflow'; // More than ~80KB of headers - if ( - tags.includes("client-error:HPE_INVALID_CONTENT_LENGTH") || - tags.includes("client-error:HPE_INVALID_TRANSFER_ENCODING") || - tags.includes("client-error:HPE_INVALID_HEADER_TOKEN") || // Invalid received (req or res) headers - tags.includes("client-error:HPE_UNEXPECTED_CONTENT_LENGTH") || // T-E with C-L - tags.includes("passthrough-error:HPE_INVALID_HEADER_TOKEN") // Invalid headers upstream, e.g. after breakpoint - ) return 'invalid-headers'; - - if (tags.includes("client-error:ERR_HTTP_REQUEST_TIMEOUT")) return 'client-timeout'; - if ( - tags.includes("client-error:ECONNABORTED") || - tags.includes("client-error:EPIPE") - ) return 'client-abort'; - - if ( - tags.filter(t => t.startsWith("passthrough-error:")).length > 0 || - tags.filter(t => t.startsWith("client-error:")).length > 0 - ) { - logError(`Unrecognized error tag ${JSON.stringify(tags)}`); - return 'unknown'; - } -} - -function typeCheck(types: readonly T[]) { - return (type: string): type is T => types.includes(type as T); -} - -const isInitialRequestError = typeCheck([ - 'invalid-http-version', - 'invalid-method', - 'client-unparseable', - 'client-unparseable-url', - 'header-overflow', - 'invalid-headers' -]); - -const isClientBug = typeCheck([ - 'client-unparseable', - 'client-unparseable-url', - 'invalid-headers' -]); - -const wasNotForwarded = typeCheck([ - 'untrusted', - 'expired', - 'not-yet-valid', - 'wrong-host', - 'tls-error', - 'host-not-found', - 'host-unreachable', - 'dns-error', - 'connection-refused' -]); - -const wasResponseIssue = typeCheck([ - 'server-unparseable', - 'connection-reset' -]); - -const wasTimeout = typeCheck([ - 'client-timeout', - 'server-timeout' -]); - -const isWhitelistable = typeCheck([ - 'untrusted', - 'expired', - 'not-yet-valid', - 'wrong-host', - 'tls-error' -]); - -const isMockable = typeCheck([ - 'host-not-found', - 'host-unreachable', - 'dns-error', - 'connection-refused', - 'connection-reset', - 'server-timeout' -]); - export const HttpErrorHeader = (p: { isPaidUser: boolean, type: ErrorType, @@ -202,7 +54,7 @@ export const HttpErrorHeader = (p: { ? This request was not forwarded successfully : // Forwarded but failed later, or unknown: This exchange was not completed successfully - } + } diff --git a/src/components/view/http/http-performance-card.tsx b/src/components/view/http/http-performance-card.tsx index 890430b7..2e4f0947 100644 --- a/src/components/view/http/http-performance-card.tsx +++ b/src/components/view/http/http-performance-card.tsx @@ -4,11 +4,7 @@ import { observer, inject } from 'mobx-react'; import { get } from 'typesafe-get'; import { styled } from '../../../styles'; -import { - HttpExchange, - TimingEvents, - ExchangeMessage -} from '../../../types'; +import { HttpExchange, ExchangeMessage } from '../../../types'; import { getReadableSize } from '../../../util/buffer'; import { asHeaderArray } from '../../../util/headers'; diff --git a/src/model/http/error-types.ts b/src/model/http/error-types.ts new file mode 100644 index 00000000..369a8ee7 --- /dev/null +++ b/src/model/http/error-types.ts @@ -0,0 +1,163 @@ +import { logError } from '../../errors'; +import { typeCheck } from '../../util'; + +export type ErrorType = + | 'untrusted' + | 'expired' + | 'not-yet-valid' + | 'wrong-host' + | 'tls-error' + | 'host-not-found' + | 'host-unreachable' + | 'dns-error' + | 'connection-refused' + | 'connection-reset' + | 'client-abort' + | 'server-timeout' + | 'client-timeout' + | 'invalid-http-version' + | 'invalid-method' + | 'client-unparseable-url' + | 'client-unparseable' + | 'server-unparseable' + | 'header-overflow' + | 'invalid-headers' + | 'unknown'; + +export const isInitialRequestError = typeCheck([ + 'invalid-http-version', + 'invalid-method', + 'client-unparseable', + 'client-unparseable-url', + 'header-overflow', + 'invalid-headers' +]); + +export const isClientBug = typeCheck([ + 'client-unparseable', + 'client-unparseable-url', + 'invalid-headers' +]); + +export const wasNotForwarded = typeCheck([ + 'untrusted', + 'expired', + 'not-yet-valid', + 'wrong-host', + 'tls-error', + 'host-not-found', + 'host-unreachable', + 'dns-error', + 'connection-refused' +]); + +export const wasServerIssue = typeCheck([ + 'server-unparseable', + 'server-timeout', + 'connection-reset' +]); + +export const wasResponseIssue = typeCheck([ + 'server-unparseable', + 'connection-reset' +]); + +export const wasTimeout = typeCheck([ + 'client-timeout', + 'server-timeout' +]); + +export const isWhitelistable = typeCheck([ + 'untrusted', + 'expired', + 'not-yet-valid', + 'wrong-host', + 'tls-error' +]); + +export const isMockable = typeCheck([ + 'host-not-found', + 'host-unreachable', + 'dns-error', + 'connection-refused', + 'connection-reset', + 'server-timeout' +]); + +export function tagsToErrorType(tags: string[]): ErrorType | undefined { + if ( + tags.includes("passthrough-error:SELF_SIGNED_CERT_IN_CHAIN") || + tags.includes("passthrough-error:DEPTH_ZERO_SELF_SIGNED_CERT") || + tags.includes("passthrough-error:UNABLE_TO_VERIFY_LEAF_SIGNATURE") || + tags.includes("passthrough-error:UNABLE_TO_GET_ISSUER_CERT_LOCALLY") + ) { + return 'untrusted'; + } + + if (tags.includes("passthrough-error:CERT_HAS_EXPIRED")) return 'expired'; + if (tags.includes("passthrough-error:CERT_NOT_YET_VALID")) return 'not-yet-valid'; + if (tags.includes("passthrough-error:ERR_TLS_CERT_ALTNAME_INVALID")) return 'wrong-host'; + + if ( + tags.filter(t => t.startsWith("passthrough-tls-error:")).length > 0 || + tags.includes("passthrough-error:EPROTO") || + tags.includes("passthrough-error:ERR_SSL_WRONG_VERSION_NUMBER") || + tags.includes("passthrough-error:ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC") || + tags.includes("passthrough-error:ERR_SSL_CIPHER_OPERATION_FAILED") || + tags.includes("passthrough-error:ERR_SSL_BAD_RECORD_TYPE") || + tags.includes("passthrough-error:ERR_SSL_INTERNAL_ERROR") + ) { + return 'tls-error'; + } + + if (tags.includes("passthrough-error:ENOTFOUND")) return 'host-not-found'; + if ( + tags.includes("passthrough-error:EHOSTUNREACH") || // No known route to this host + tags.includes("passthrough-error:ENETUNREACH") // Whole network is unreachable + ) return 'host-unreachable'; + if (tags.includes("passthrough-error:EAI_AGAIN")) return 'dns-error'; + if (tags.includes("passthrough-error:ECONNREFUSED")) return 'connection-refused'; + if (tags.includes("passthrough-error:ECONNRESET")) return 'connection-reset'; + if (tags.includes("passthrough-error:ETIMEDOUT")) return 'server-timeout'; + if ( + tags.includes("passthrough-error:HPE_INVALID_CONSTANT") || + tags.includes("passthrough-error:ERR_INVALID_HTTP_TOKEN") || + tags.includes("passthrough-error:ERR_HTTP_INVALID_STATUS_CODE") || + tags.includes("passthrough-error:ERR_INVALID_CHAR") + ) { + return 'server-unparseable'; + } + + if (tags.includes("http-2") || tags.includes("client-error:HPE_INVALID_VERSION")) { + return 'invalid-http-version'; + } + + if (tags.includes("client-error:HPE_INVALID_METHOD")) return 'invalid-method'; // QWE / HTTP/1.1 + if (tags.includes("client-error:HPE_INVALID_URL")) return 'client-unparseable-url'; // http:// + if ( + tags.includes("client-error:HPE_INVALID_CONSTANT") || // GET / HTTQ <- incorrect constant char + tags.includes("client-error:HPE_INVALID_EOF_STATE") // Unexpected 0-length packet in parser + ) return 'client-unparseable'; // ABC/1.1 + if (tags.includes("client-error:HPE_HEADER_OVERFLOW")) return 'header-overflow'; // More than ~80KB of headers + if ( + tags.includes("client-error:HPE_INVALID_CONTENT_LENGTH") || + tags.includes("client-error:HPE_INVALID_TRANSFER_ENCODING") || + tags.includes("client-error:HPE_INVALID_HEADER_TOKEN") || // Invalid received (req or res) headers + tags.includes("client-error:HPE_UNEXPECTED_CONTENT_LENGTH") || // T-E with C-L + tags.includes("passthrough-error:HPE_INVALID_HEADER_TOKEN") // Invalid headers upstream, e.g. after breakpoint + ) return 'invalid-headers'; + + if (tags.includes("client-error:ERR_HTTP_REQUEST_TIMEOUT")) return 'client-timeout'; + if ( + tags.includes("client-error:ECONNABORTED") || + tags.includes("client-error:EPIPE") + ) return 'client-abort'; + + if ( + tags.filter(t => t.startsWith("passthrough-error:")).length > 0 || + tags.filter(t => t.startsWith("client-error:")).length > 0 + ) { + logError(`Unrecognized error tag ${JSON.stringify(tags)}`); + return 'unknown'; + } +} \ No newline at end of file diff --git a/src/util/index.ts b/src/util/index.ts index 2b0dfd4f..1ef3d52c 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -21,6 +21,10 @@ export function firstMatch(...tests: Array | R | undefined>): R | und } } +export function typeCheck(types: readonly T[]) { + return (type: string): type is T => types.includes(type as T); +} + export function longestPrefix(baseString: string, ...strings: string[]) { let prefix = ""; const shortestLength = Math.min(