Skip to content

Commit

Permalink
feat: basic handling of timeouts and other http errors (both in stora…
Browse files Browse the repository at this point in the history
…ge and frontend)
  • Loading branch information
hartym committed Mar 22, 2024
1 parent 38c7d01 commit 4378ade
Show file tree
Hide file tree
Showing 13 changed files with 134 additions and 32 deletions.
7 changes: 7 additions & 0 deletions docs/reference/core/harp.http.errors.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
harp.http.errors
================

.. automodule:: harp.http.errors
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/reference/core/harp.http.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Submodules
.. toctree::
:maxdepth: 1

harp.http.errors
harp.http.requests
harp.http.responses
harp.http.serializers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import { Message, Transaction } from "Models/Transaction"
export const getResponseFromTransactionMessages = (transaction: Transaction) => {
return {
response: transaction.messages?.find((message: Message) => message.kind === "response"),
error: transaction.messages?.find((message: Message) => message.kind === "error"),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import tw, { styled } from "twin.macro"

import { ResponseStatusBadge } from "Components/Badges/ResponseStatusBadge"
import { Message } from "Models/Transaction"
import { Badge } from "mkui/Components/Badge"

interface ResponseHeadingProps {
as?: ElementType
response: Message
response?: Message
error?: Message
}

const StyledResponseHeading = styled.h1`
Expand All @@ -17,18 +19,26 @@ const StyledResponseHeading = styled.h1`
export function ResponseHeading({
as = "h1",
response,
error,
...moreProps
}: ResponseHeadingProps & HTMLAttributes<HTMLElement>) {
const responseSummary = response.summary.split(" ")
return (
<StyledResponseHeading as={as} {...moreProps}>
<div className="flex items-center">
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mr-1">
<ArrowLeftIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />
</span>
<ResponseStatusBadge statusCode={parseInt(responseSummary[1])} />
{/* todo size <span>...kB</span>*/}
</div>
</StyledResponseHeading>
)
if (response !== undefined) {
const responseSummary = response.summary.split(" ")
return (
<StyledResponseHeading as={as} {...moreProps}>
<div className="flex items-center">
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mr-1">
<ArrowLeftIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />
</span>
<ResponseStatusBadge statusCode={parseInt(responseSummary[1])} />
{/* todo size <span>...kB</span>*/}
</div>
</StyledResponseHeading>
)
}
if (error !== undefined) {
return <Badge color="red">{error.summary}</Badge>
}

return <span className="text-xs text-gray-500">n/a</span>
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ const transactionColumnTypes = {
elapsed: {
label: "Duration",
get: (row: Transaction) => (row.elapsed ? Math.trunc(row.elapsed) / 1000 : null),
format: (x: number) => {
return (
<div>
<PerformanceRatingBadge duration={x} /> {formatDuration({ seconds: x })}{" "}
</div>
)
format: (x: number | null) => {
if (x !== null) {
return (
<div>
<PerformanceRatingBadge duration={x} /> {formatDuration({ seconds: x })}{" "}
</div>
)
}
},
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"
import { format } from "date-fns"
import { ReactNode, useState } from "react"
import { QueryObserverSuccessResult } from "react-query/types/core/types"

Expand All @@ -25,10 +26,11 @@ import { SettingsTable } from "../System/Components"
interface FoldableProps {
open?: boolean
title: ReactNode
subtitle?: ReactNode
children: ReactNode
}

function Foldable({ open = true, title, children }: FoldableProps) {
function Foldable({ open = true, title, subtitle, children }: FoldableProps) {
const [isOpen, setIsOpen] = useState(open)

return (
Expand All @@ -46,6 +48,7 @@ function Foldable({ open = true, title, children }: FoldableProps) {
<ChevronDownIcon className="h-4 w-4 min-w-4 text-gray-600" />
)}
</H5>
{subtitle ?? null}
{/* actual foldable content */}
<div className={"mt-2 space-y-2 overflow-x-auto " + (isOpen ? "" : "hidden")}>{children}</div>
</div>
Expand All @@ -70,6 +73,10 @@ function ShortMessageSummary({ kind, summary }: { kind: string; summary: string
</span>
)
}

if (kind == "error") {
return <span className="font-normal text-red-800 font-mono text-xs">{summary}</span>
}
}

function MessageHeaders({ id }: { id: string }) {
Expand Down Expand Up @@ -98,11 +105,13 @@ function MessageBody({ id }: { id: string }) {
const query = useBlobQuery(id)

if (query && query.isSuccess && query.data !== undefined) {
return (
<div className="px-2">
<PrettyBody content={query.data.content} contentType={query.data.contentType} />
</div>
)
if (query.data.content.length) {
return (
<div className="px-2">
<PrettyBody content={query.data.content} contentType={query.data.contentType} />
</div>
)
}
}

return null
Expand Down Expand Up @@ -163,9 +172,18 @@ export function TransactionListPageOnQuerySuccess({
<Foldable
title={
<>
{ucfirst(message.kind)} <ShortMessageSummary kind={message.kind} summary={message.summary} />
<span className="mr-2">{ucfirst(message.kind)}</span>
<ShortMessageSummary kind={message.kind} summary={message.summary} />
</>
}
subtitle={
message.created_at ? (
<span className="text-xs text-gray-500">
{format(new Date(message.created_at), "PPPPpppp")}
</span>
) : undefined
}
open={message.kind != "error"}
>
<MessageHeaders id={message.headers} />
<MessageBody id={message.body} />
Expand Down
2 changes: 0 additions & 2 deletions harp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ def run(config: Config):
asyncio.run(server.serve())


print(__parsed_version__)

__all__ = [
"Config",
"ROOT_DIR",
Expand Down
2 changes: 2 additions & 0 deletions harp/http/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .errors import HttpError
from .requests import HttpRequest
from .responses import AlreadyHandledHttpResponse, HttpResponse, JsonHttpResponse
from .serializers import HttpRequestSerializer, get_serializer_for
Expand All @@ -9,6 +10,7 @@
"AlreadyHandledHttpResponse",
"BaseHttpMessage",
"BaseMessage",
"HttpError",
"HttpRequest",
"HttpRequestBridge",
"HttpRequestSerializer",
Expand Down
28 changes: 28 additions & 0 deletions harp/http/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from functools import cached_property

from multidict import MultiDict, MultiDictProxy

from .typing import BaseHttpMessage


class HttpError(BaseHttpMessage):
kind = "error"

def __init__(self, message: str, /, *, exception: Exception = None):
super().__init__()
self.message = message
self.exception = exception

@cached_property
def headers(self) -> MultiDictProxy:
return MultiDictProxy(MultiDict())

@cached_property
def body(self) -> bytes:
if self.exception:
msg = str(self.exception)
out = type(self.exception).__name__
if msg:
out += f": {msg}"
return out.encode()
return self.message.encode()
23 changes: 23 additions & 0 deletions harp/http/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from httpx import codes

from .errors import HttpError
from .requests import HttpRequest
from .responses import HttpResponse
from .typing import BaseHttpMessage, BaseMessage, MessageSerializer
Expand Down Expand Up @@ -70,11 +71,33 @@ def summary(self) -> str:
return f"HTTP/1.1 {self.wrapped.status} {reason}"


class HttpErrorSerializer(BaseHttpMessageSerializer):
"""
Serialize an HTTP error object into string representations for different message parts:
- summary: the error message
- headers: empty
- body: stack trace (xxx this may change, maybe too much info and too much internal)
The main goal of this serializer is to prepare an error message for storage.
"""

wrapped: HttpError

@cached_property
def summary(self) -> str:
return self.wrapped.message


def get_serializer_for(message: BaseMessage) -> MessageSerializer:
if isinstance(message, HttpRequest):
return HttpRequestSerializer(message)

if isinstance(message, HttpResponse):
return HttpResponseSerializer(message)

if isinstance(message, HttpError):
return HttpErrorSerializer(message)

raise ValueError(f"No serializer available for message type: {type(message)}")
3 changes: 3 additions & 0 deletions harp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@

#: Pagination size for api endpoints
PAGE_SIZE = 20

#: Default timeout for http requests
DEFAULT_TIMEOUT = 10.0
3 changes: 2 additions & 1 deletion harp_apps/http_client/__app__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from harp.config import Application
from harp.config.events import FactoryBindEvent
from harp.settings import DEFAULT_TIMEOUT


class HttpClientApplication(Application):
Expand All @@ -13,7 +14,7 @@ async def on_bind(self, event: FactoryBindEvent):
cache_transport = AsyncCacheTransport(transport=transport)
event.container.add_instance(
AsyncClient(
timeout=10.0,
timeout=DEFAULT_TIMEOUT,
transport=cache_transport,
)
)
14 changes: 11 additions & 3 deletions harp_apps/proxy/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from harp import __parsed_version__, get_logger
from harp.asgi.events import MessageEvent, TransactionEvent
from harp.http import HttpRequest, HttpResponse
from harp.http import HttpError, HttpRequest, HttpResponse
from harp.http.requests import WrappedHttpRequest
from harp.models import Transaction
from harp.utils.guids import generate_transaction_id_ksuid
Expand Down Expand Up @@ -98,14 +98,22 @@ async def __call__(self, request: HttpRequest):
# PROXY RESPONSE
try:
p_response: httpx.Response = await self.http_client.send(p_request)
except httpx.ConnectError:
except httpx.ConnectError as exc:
logger.error(f"▶▶ {request.method} {url} (unavailable)", transaction_id=transaction.id)
await self.adispatch(
EVENT_TRANSACTION_MESSAGE, MessageEvent(transaction, HttpError("Unavailable", exception=exc))
)

# todo add web debug information if we are not on a production env
return HttpResponse(
"Service Unavailable (remote server unavailable)", status=503, content_type="text/plain"
)
except httpx.TimeoutException:
except httpx.TimeoutException as exc:
logger.error(f"▶▶ {request.method} {url} (timeout)", transaction_id=transaction.id)
await self.adispatch(
EVENT_TRANSACTION_MESSAGE, MessageEvent(transaction, HttpError("Timeout", exception=exc))
)

# todo add web debug information if we are not on a production env
return HttpResponse("Gateway Timeout (remote server timeout)", status=504, content_type="text/plain")

Expand Down

0 comments on commit 4378ade

Please sign in to comment.