Engineering

Elixir runtime configuration trap

Elixir 1.9 introduced mix release, which has standardised the way releases in the Elixir ecosystem are prepared. It also provided Elixir core with an easy way to configure applications in runtime. Before, Elixir 1.9 community solved this problem in different ways. For example, by using {:system, "VAR"} tuple, REPLACE_OS_VARS provided in early versions of distillery or using other external libraries. So finding out how the new project that you’ve just entered handled runtime configuration wasn’t that straightforward.

Luckily Elixir 1.9 introduced config/releases.exs and what’s even more important is that there’s documented guidelines which help provide runtime configuration for Elixir apps. Later on, Elixir 1.11 introduced config/runtime.exs and made it the recommended place to put your runtime configuration.

Small personal notice: I love how the Elixir Core team listens to the community and targets the right problems at the right time.

So now we have the convention recommended by Elixir guidelines on handling runtime configuration. So all the problems regarding runtime configuration should go away? Not exactly.

The problem

Recently I’ve noticed recurring bug in our code base.

defmodule MyApp.ExternalServiceClient do 
    @external_service_url Application.get_env(:my_app, :external_service_url)
    
    def get_info do
        Req.get!(@external_service_url)
    end
end 

Did you notice the problem here?

Module attributes are evaluated at compile time, so this code could not work as expected with the configuration provided at runtime. For example:

config/config.exs
...
config :my_app, external_service_url: "http://dev-env.com/service"
...

config/runtime.exs
...
config :my_app, external_service_url: System.get_env("EXTERNAL_SERVICE_URL")
...

With the given configuration MyApp.ExternalServiceClient.get_info/0 won’t crash in the release build but it won’t request URL provided in EXTERNAL_SERVICE_URL environment variable but request http://dev-env.com/service. In the worst-case scenario, if you don’t have a firewall between your environments, it can lead to nasty bugs, where your prod environment will target dev. You will notice this, only if QA or end-users will report that to you. It is definitely too late in an agile environment.

How to fix that?

Fixing this mistake is quite simple, you can create function which will call Application.get_env/3 at runtime:

defmodule MyApp.ExternalServiceClient do 
    defp external_service_url, do: Application.get_env(:my_app, :external_service_url)
    
    def get_info do
        Req.get!(external_service_url()
    end
end 

A bit harder part of getting rid of this kind of bug is preventing them.

How to avoid this kind of error?

To be honest, during my Elixir journey, I’ve made the same mistake a few times. Maybe it is just me and my bad memory, but imagine that you have a dozen Elixir engineers. Even if you share the postmortem with them, there is a high probability that the mistake will be made again. If you have worked in larger teams or organisations, you’ve probably noticed that sharing knowledge can be challenging.

So what can be done about that?

First, you can use Application.compile_env/3 introduced in Elixir 1.10, which helps you detect a bug faster. It will check if the value of configuration in runtime is the same as during compilation, and if there is a difference, it will raise an error during BEAM startup.

Raising errors during BEAM startup is quite late in the feedback loop, but it will help you avoid nasty errors, as I’ve described earlier.

Could we improve our solution? Sure, we can also make sure that our teammates won’t forget about using Application.compile_env/3 in module attributes.

Credo for the rescue

"Credo is a static code analysis tool for the Elixir language with a focus on teaching and code consistency."

If you haven’t been using it, you should definitely try it out and select checks that will help you improve your code's quality. You can also use it as a tool to enforce code style if formatted isn’t enough for you. As a lazy engineer, I highly recommend plugging it in your CI/CD so you won’t have to remember about running it 😀

How will it help you avoid the bug described earlier? Make sure your Credo config doesn’t disable Credo.Check.Warning.ApplicationConfigInModuleAttribute check (it is turned by default from Credo 1.5.0). It will ensure that only Application.compile_env/3 or Application.compile_env!/2 is used in the module attribute to get the configuration.

That’s it! If you would like to talk with me, feel free to hit me up @MichalDolata