I like where our design ended up for basic-cli
error handling (Task {} [Exit I32 Str]
) and I was thinking we could do something similar for basic-webserver
- accept a main
with any error type you like, and by default return HTTP status 500 (Internal Server Error) if any error occurs
so that way, just like in basic-cli
, you can get up and running with no error handling and just add a main : Request -> Task Response []
annotation to enable full mandatory handling of all errors whenever you're ready
thoughts?
Hm, I don’t think it works as well for servers. In a CLI application that you’re running in a given machine, you can’t expose anything that the user doesn’t already have access to.
However, in a server/client context, you need to carefully control what you expose.
It could work if you didn’t return the error to the client or if you only did so in dev mode. That said, I think this is a platform where it’s more important to encourage good error handling.
I appreciate that point, but it's super easy to harden it for release - literally adding one type annotation :big_smile:
I wonder if we can have a different handler implementation and swap it out for cargo build
verse cargo build --release
I've been thinking about/planning to do some cleanup and improvements for basic-webserver soon. I'm just wanting to finish cleaning up the basic-cli build process I've started on. So if we come up with a nice API, happy to make those changes too
I'd rather not make things work differently for dev vs release if we can avoid it
What about some kind of logging. Like the Task {} [Exit I32 Str]
could append to a logfile or something
Richard Feldman said:
I appreciate that point, but it's super easy to harden it for release - literally adding one type annotation :big_smile:
I guess my point is that there are good CLI apps were this default crash behavior would be what you want forever. Whereas in a server, you really don’t want this beyond the initial experimentation stage.
oh that's interesting - like it returns error 500 but doesn't say what the problem was, but then just logs the Inspect.toStr
to stderr?
that sounds interesting - would basically only affect initial experimentation then, yeah?
yeah @Agus Zubiaga I agree, but I'm mainly thinking about a way to improve the experience of the initial experimentation stage in a way where it's really easy to transition to production once you're ready
What I like about that is while I'm developing with localhost I can see any errors in the terminal.
It's been a surprisingly nice workflow with the latest basic-cli API that I did not expect
we could actually make that a default thing that basic-webserver does automatically
Like I've started tagging errors everywhere and it makes it so easy to see where things are broken, and experiment/iterate super quickly
Yeah, that seems like a good compromise
so basically whenever basic-webserver
receives a Response
where the status code is in the 500 range, it just automatically logs to stderr whatever the response was?
and then it does the same if it gets back a Task.err
Oh, I was thinking it would only do this for Task.err
I was too at first! But this comment:
Luke Boswell said:
Like I've started tagging errors everywhere and it makes it so easy to see where things are broken, and experiment/iterate super quickly
...wouldn't you still want that even after you start handling them gracefully? :thinking:
I don’t know if that would work. Handling them gracefully might mean you won’t return a 5xx.
I'm not sure, but I think its nice to have a catchall for unhandled cases. Is it ever valid to send a 500 deliberately??
Like maybe a downstream service failed or something??
Yeah, it can be when you know what went wrong but don’t want to expose it.
right
for example, a "this should never happen" situation :big_smile:
or yeah, you called out to another service and it gave an error, so you can't proceed
You could have a platform feature to log requests and responses and some logic to filter them
true!
I mentioned 500s specifically because if gracefully handling it results in a non-500 error, I wouldn't want to see that
e.g. if it's a 403 Forbidden then I don't think I need more info logged haha
Right. I guess it might be a convenient default
personally whenever I'm working on a server and I see a 500 my next step is to look into "ok what happened?" and so default logging sounds nice to me! :big_smile:
Now the problem with the platform logging to stderr is that people are gonna want to log to other places such as Sentry
I think that's something we should look into anyway, e.g. for things like this:
Agus Zubiaga said:
You could have a platform feature to log requests and responses and some logic to filter them
I'm not sure exactly how we should expose that
Something like this could work:
app [main] { pf: "https://../basic-webserver" }
import pf.Logging
main = \req ->
Logging.onWrite! \level, message ->
# send to Sentry
# handle request
one idea is that since you can already do that in userspace (e.g. instead of writing all your logic to return Response
, instead return something like MyCustomLoggableThing
and then translate that to "first log whatever we want and then return the Response
that main
wants") just offer a way to log crash
es, e.g. Str -> Task Response []
another possibility is to allow logging hooks for all I/O too, e.g. onCrash
, onFileRead
, onFileWrite
, onHttpRequest
, etc.
since the platform is in charge of all that
so yeah I think there's some separate design work to be done there
Also instead of
#[no_mangle]
pub unsafe extern "C" fn roc_panic(msg: &RocStr, _tag_id: u32) {
panic!("The Roc app crashed with: {}", msg.as_str());
}
We might want to turn that into a 500 also and log that too.
oh yeah for sure!
also while we're at it, I'd like to change the entrypoint name from main
to something more like handleRequest
respond
maybe?
I like respond
. You might not actually explicitly respond, but the platform will always respond for you :smile:
https://github.com/roc-lang/basic-webserver/issues/49 <-- made an issue just to track this thread. I don't want to lose these ideas
What if in addition to respond
, the app provided a config
value:
app [respond, config] { pf: "https://../basic-webserver" }
respond = \req ->
# handle request
config =
import pf.Config
Config.default
|> Config.port 8080
|> Config.onLog \req, level, message ->
# Log to Sentry
|> Task.ok
```
I guess it makes the hello world a little more wordy, but I think we are going to need something like that eventually
I'm wondering if we will need passed in allocators and context though before I could make roc_panic log errors back on the HTTP response? :thinking:
@Agus Zubiaga what if we supported another roc app as a plugin that could be used to configure the server on startup?
So just roc server.roc
would use default config. Or roc server.roc config.roc
runs the configuration plugin.
Interesting. That would require a new language feature, right?
Cause' otherwise the platform would have to compile the config app on the fly
I don't think any new features are required. -- assuming the config is just a value or data that implements Encoding and Decoding
We could have the plugin do some setup, maybe it has extra effects or superpowers even, and then it could produce an immutable/ready only config object that is given to each request.
I don't think roc can run two apps like that
I am almost certain I can have the webserver run one app on startup and then another for handling requests
Or is the issue the roc build
part?
I'll need to experiment
Well, if you build config.roc
first, then I guess server.roc
can execute it as a command
I was thinking the host could look at its args and if it finds a config.roc
it roc build --lib
that and then dynamically loads it.
ah right, that's what I meant with building it on the fly
So the platform ships with the compiler as a lib or it has to be available in the system
Roc cli has to be available in the system
And the plugin would use a URL release I assume
Thats interesting, but it doesn’t seem very convenient for deployment
Or maybe I'm just overcomplicating this... we just have a second function config
alongside respond
like you suggested
Yeah I think I'm getting carried away with the art of the possible... just throwing ideas out there
We could maybe make platform requires
work like module params in that they are just a record pattern. That would allow us to have optional fields, so config
wouldn’t need to be specified if you want the default.
That might be nicer than using a record with two functions for main.
I just noticed, from the platform side the requires currently is a record or at least
platform ""
requires { Model, respond, configure } { ... }
Wheras the app is a list
provides [Model, respond, configure] to web
Luke Boswell said:
That might be nicer than using a record with two functions for main.
Honestly, that seems totally fine
Just threw a sneaky Model
in there... it would be awesome to have some kind of in-memory cache
I really like how Elm
implements it's main
function. It needs to be assigned a value of type Program
, and the Browser
module exports a couple different functions to create a Program
.
https://package.elm-lang.org/packages/elm/browser/latest/Browser
The first function displayed is sandbox
, and it takes the bare minimum options to construct a basic program, ideal for getting started with Elm and it's basic architecture. Next is element
, which adds the option for the program to listen to outside events. Next is document
, which adds the capability to perform side-effects. Finally there's application
, which allows the program to also own page navigation.
I really like how this teaches you the Elm 'platform' one concept at a time. I think it'd be great if Roc platforms could do something similar.
I wonder what you think (and maybe this has been discussed before) about implementing it like this:
app [main] { pf: "https://../basic-webserver" }
main = loggingServer { respond, onLog }
respond = \req ->
# handle request
onLog = \msg ->
# log something
Interesting idea... could we achieve this using Abilities? maybe the platform requires a type that implements the WebServerAbility
, and there are a few default implementations provided.
The first might be SimpleServer { respond }
, second might be WithLogging { respond, handleLogs }
, etc.
The platform then calls all of the functions it needs to build the record to provide to the host. Some of these may be default implementations.
I don’t know if you need different kinds of servers. You could have something like this:
app [server] {...}
import pf.Http exposing [Server, Request, Response]
server : Task Server *
server = Http.serve! { respond }
respond : Request -> Task Response *
respond = \req ->
...
Http.serve
takes a record with optional fields so you can pass other config if you need to:
server : Task Server *
server = Http.serve! {
respond,
port: Env.get! "PORT",
onLog: \x -> Cloudwatch.log x …
}
If a record with optional fields isn’t enough, you can also use the builder pattern.
yeah I'd like to move to this type of API, but it requires being able to send closures to hosts, which we can't do yet :sweat_smile:
Folkert and I have been working on that for awhile, but it's tricky to get right
unfortunately, looks like implementing this idea runs into https://roc.zulipchat.com/#narrow/stream/395097-compiler-development/topic/Alias.20analysis.20error.20across.20modules
thread 'main' panicked at crates/compiler/gen_llvm/src/llvm/build.rs:5759:19:
Error in alias analysis: error in module ModName("UserApp"), function definition FuncName("\x0f\x00\x00\x00\x05\x00\x00\x00\xce\x826j\x04j\xb3\xbf"), definition of value binding ValueId(3): could not find func in module ModName("UserApp") with name FuncName("\x02\x00\x00\x00\x00\x00\x00\x00\x1d\xc8\xcdx>Y\x17\x03")
So to summarise the key points from above, I am going to make the following changes in a PR:
main
to respond
change the API to accept tag union of errors e.g. respond : Request -> Task Response [ServerErr Str]_
.
ServerErr
tag, it will respond with a status code of 500 and log the Str
to stderr (not in the response). Inspect.toStr
logged to stderr.attempt to convert roc_panic
to respond with a 500 and log the stderr, instead of crashing the server
For any experienced rust people ... to implement the roc_panic
handling I wonder if we can use std::panic::catch_unwind to wrap the call into roc.
My theory is that then when roc_panic
gets called and we panic!
in there, that is be somewhere down the stack and this will catch that so we can handle it appropriately.
Luke Boswell said:
For any experienced rust people ... to implement the
roc_panic
handling I wonder if we can use std::panic::catch_unwind to wrap the call into roc.My theory is that then when
roc_panic
gets called and wepanic!
in there, that is be somewhere down the stack and this will catch that so we can handle it appropriately.
I know @Folkert de Vries has looked into this for Nea!
this is dangerous in practice. Rust panics are not normally allowed to cross an FFI boundary either way. There is now a special calling convention "C-unwind" but even then I think the unwinding through roc code won't really work?
I mean we could make it work technically I guess (though I have no idea how)
hm, what should we do instead?
setjmp/longjmp?
maybe. are we sure we can skip running destructors ?
that would just leak memory otherwise
hm, yeah good point
this is a good argument for doing the unwinding inside the generated Roc code and having the Roc call return a Result :sweat_smile:
(along with effect interpreters and passing in an Allocator struct)
If exceptions are truly rare, returning a result generally has worse performance characteristics. Especially if the result has to accumulate some sort of information related to the stack trace on the error case.
True exceptions with unwinding are a better trade off if we expect panics to be rare (which I think we do).
true
although I guess setjmp has a cost even if we don't end up doing the longjmp
and that cost will be higher than the Result handling cost
setjmp has a very minor cost. Just saving like 8 registers or something
Anyway, I realized that this is kinda something we can easily make an implementation detail
Always return a result to the host for a panic.
Internally, we can do:
We could do any of those and still just return a result to a host that represents the potential panic.
yeah that sounds awesome! :grinning:
Last updated: Jul 06 2025 at 12:14 UTC