Skip to content
69 changes: 64 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,53 @@ def main() raises -> None:
`raise_for_status()` treats any 2xx as success, so `201 Created` and `204 No Content`
do not raise.

### Handling request errors

When a request fails *before* a usable response comes back — a DNS failure, a refused
connection, a timeout, a TLS problem — floki raises a `RequestError`. (This is distinct
from `HTTPError`, which `raise_for_status()` raises when a response *was* received but
carried a non-2xx status.)

A `RequestError` wraps one of several concrete error types:

| Concrete type | Raised when |
| --- | --- |
| `TimeoutError` | The request exceeded its configured timeout. |
| `ConnectionError` | The server couldn't be reached (DNS failure, refused/dropped connection). |
| `TLSError` | A TLS/SSL handshake or certificate-verification failure. |
| `TooManyRedirectsError` | The request followed more redirects than allowed. |
| `TransportError` | Any other transport-level failure. |

Use `error.isa[T]()` to test which type it holds and `error[T]` to pull the concrete
value out:

```mojo
import floki
from floki import ConnectionError, TimeoutError, TLSError

def main() raises -> None:
try:
var r = floki.get("https://example.com")
print(r.status.code)
except e: # `e` is a `RequestError`
if e.isa[TimeoutError]():
print("Timed out:", e[TimeoutError])
elif e.isa[ConnectionError]():
print("Could not connect:", e[ConnectionError])
elif e.isa[TLSError]():
print("TLS failure:", e[TLSError])
else:
print("Request failed:", e)
```

- `error.isa[T]()` returns `True` when the `RequestError` currently holds a value of
type `T`.
- `error[T]` (the `__getitem_param__` subscript) returns the underlying value of type
`T`. Only call it after `isa[T]()` has confirmed the type.

If you don't need to branch on the category, a `RequestError` is `Writable`, so you can
print or format it directly for a human-readable message.

### Authentication

Basic and Bearer helpers are built in, and any type implementing the `Auth` trait
Expand Down Expand Up @@ -250,7 +297,7 @@ def main() raises -> None:
var r = session.get("https://example.com")
```

The delay before retry _n_ is `backoff_factor * 2 ** (n - 1)` seconds.
The delay before retry *n* is `backoff_factor * 2 ** (n - 1)` seconds.

### Proxies

Expand All @@ -267,7 +314,7 @@ def main() raises -> None:
"http://proxy.example:8080",
username="user",
password="secret",
no_proxy="localhost,127.0.0.1",
no_proxy=["localhost", "127.0.0.1"],
)
)
var r = session.get("https://example.com")
Expand Down Expand Up @@ -298,10 +345,22 @@ def main() raises -> None:

## TODO

- Add an option for streaming responses instead of loading it all into memory.
### TODO: Features

- Streaming responses — the whole body is buffered into a `List[Byte]`; large downloads and SSE need incremental access.
- `multipart/form-data` file uploads (files=): today only `x-www-form-urlencoded` is supported, so real file uploads aren't possible.
- Response encoding awareness: `as_text()` assumes UTF-8 and raises otherwise; it ignores the charset in `Content-Type`. At minimum, expose a lossy decode fallback.
- `Accept-Encoding` / transparent decompression: ability to set it so gzip/deflate responses come back decoded (libcurl does this if you enable it).
- Multi-value response headers: Headers wraps `Dict[String, String]`, so repeated headers collapse (`Set-Cookie` is handled separately by the jar, but others are lost).
- Add support for passing Dict data to session methods. Just passing a dict literal is a little limiting. I've tried, but it gets very hairy trying to convert it to an emberjson Value object.

### TODO: Optimizations

- Cleanup cookie parsing code, it seems pretty slow.
- I should update `mojo-curl` bindings to indicate which Easy handle methods mutate the handle. It's deceptive that they're all marked as borrowing self immutably, because it can update the Easy handle's internal state via FFI.

### TODO: Bugs

- Sus out the myriad of bugs and edge cases that may arise as libcurl and requests can do A LOT of things, that I've never used before. Please open issues and open PRs to help address these gaps where possible.
- Add methods to free Session explicitly, same with Easy handles.
- Add support for passing Dict data to session methods. Just passing a dict literal is a little limiting. I've tried, but it gets very hairy trying to convert it to an emberjson JSON object.

Reminder, this is a hobby project! You're free to fork it and make changes as you see fit.
47 changes: 47 additions & 0 deletions examples/error_handling.mojo
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import floki
from floki import (
ConnectionError,
RequestError,
TimeoutError,
TLSError,
TooManyRedirectsError,
TransportError,
)
from floki.session import Session
from floki.timeout import Timeout


def main() raises -> None:
# `floki.get` (and every other request function) raises `RequestError` when the
# transfer fails before a response is received. `RequestError` wraps one of
# several concrete error types; use `isa[T]()` to test which one it holds and
# `error[T]` to pull the concrete value out.

# A 1ms total timeout guarantees the transfer fails, so we can see the handling.
var session = Session(timeout=Timeout(total=0.001))

try:
var r = session.get("https://example.com")
print("Got a response:", r.status.code)
except e:
# `e` is a `RequestError`. Branch on the underlying kind:
if e.isa[TimeoutError]():
print("Request timed out:", e[TimeoutError])
elif e.isa[ConnectionError]():
print("Could not reach the server:", e[ConnectionError])
elif e.isa[TLSError]():
print("TLS/SSL failure:", e[TLSError])
elif e.isa[TooManyRedirectsError]():
print("Too many redirects:", e[TooManyRedirectsError])
elif e.isa[TransportError]():
print("Transport-level failure:", e[TransportError])
else:
# Any other low-level error carried by the `RequestError`.
print("Request failed:", e)

# If you don't care about the category, the `RequestError` is `Writable`, so you
# can print or format it directly for a human-readable message.
try:
_ = session.get("https://example.com")
except e:
print("Request failed:", e)
10 changes: 10 additions & 0 deletions floki/__init__.mojo
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
"""Floki: An HTTP client library for Mojo!"""
from floki.auth import Auth, BasicAuth, BearerAuth, NoAuth
from floki.errors import (
ConnectionError,
ErrorKind,
FlokiError,
RequestError,
TimeoutError,
TLSError,
TooManyRedirectsError,
TransportError,
)
from floki.forms import FormData
from floki.functions import delete, get, head, options, patch, post, put
from floki.headers import Headers
Expand Down
6 changes: 3 additions & 3 deletions floki/body.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ struct Body(Copyable, Equatable, Sized, Writable):
"""Deserializes the body into a value of the given type.

Use this when you have a struct (or other deserializable type) to parse
the body into. For ad-hoc, untyped access, use `json()` instead.
the body into. For ad-hoc, untyped access, use `as_json()` instead.

Parameters:
T: The type to deserialize the body into.
Expand All @@ -79,10 +79,10 @@ struct Body(Copyable, Equatable, Sized, Writable):
"""Parses the body as a dynamic JSON document for ad-hoc access.

Use this to inspect a response without declaring a target type, e.g.
`body.json()["data"]`. To deserialize into a struct, use `as_json[T]()`.
`body.as_json()["data"]`. To deserialize into a struct, use `as_json[T]()`.

Returns:
The body content parsed as an `emberjson.JSON` value.
The body content parsed as an `emberjson.Value`.

Raises:
Error: if the body is empty or cannot be parsed as JSON.
Expand Down
Loading
Loading