Compare commits

..

50 Commits

Author SHA1 Message Date
05a513898a fix: 🐛 Ignore URL parameters 2024-07-01 23:59:10 +04:00
3c5b8f6337 test: 🧪 Missing tests added (existing corrected) 2024-07-01 23:44:27 +04:00
df2163e41e feat: fasthttp is back 2024-07-01 23:41:26 +04:00
afaef54ddf wip: 🔕 temporary commit 2024-06-30 18:12:52 +04:00
e3499a940c wip: 🔕 temporary commit 2024-06-29 21:47:20 +04:00
f3c2646f4e wip: 🔕 temporary commit 2024-06-29 21:33:43 +04:00
e91a6f99fd wip: 🔕 temporary commit 2024-06-29 21:12:08 +04:00
2664c467c0 wip: 🔕 temporary commit 2024-06-29 21:09:56 +04:00
52063654ab wip: 🔕 temporary commit 2024-06-29 20:47:18 +04:00
616e4ad7e2 wip: 🔕 temporary commit 2024-06-29 20:44:08 +04:00
1563a8c226 wip: 🔕 temporary commit 2024-06-29 20:32:37 +04:00
8b29a95bb1 wip: 🔕 temporary commit 2024-06-29 20:25:58 +04:00
b7ed431c8f wip: 🔕 temporary commit 2024-06-29 20:06:06 +04:00
6766f3f787 wip: 🔕 temporary commit 2024-06-29 19:55:55 +04:00
75476a22c0 wip: 🔕 temporary commit 2024-06-29 19:53:40 +04:00
4e653f37d3 wip: 🔕 temporary commit 2024-06-29 19:50:23 +04:00
4e7abaafbf wip: 🔕 temporary commit 2024-06-29 19:45:35 +04:00
9e719c7feb wip: 🔕 temporary commit 2024-06-29 19:38:13 +04:00
cbbe2ea631 wip: 🔕 temporary commit 2024-06-29 19:37:24 +04:00
4bb567b1d6 wip: 🔕 temporary commit 2024-06-29 19:11:21 +04:00
cea34f0475 wip: 🔕 temporary commit 2024-06-29 16:34:03 +04:00
86aa4aab93 wip: 🔕 temporary commit 2024-06-29 15:07:53 +04:00
7ed6ec414f wip: 🔕 temporary commit 2024-06-29 15:04:38 +04:00
14815b4c73 wip: 🔕 temporary commit 2024-06-29 14:54:47 +04:00
b7228c3933 wip: 🔕 temporary commit 2024-06-29 02:59:47 +04:00
1fa6e1de4f wip: 🔕 temporary commit 2024-06-29 00:49:59 +04:00
5976f2903d wip: 🔕 temporary commit 2024-06-28 19:28:35 +04:00
63b125e080 wip: 🔕 temporary commit 2024-06-28 16:57:06 +04:00
a759504971 wip: 🔕 temporary commit 2024-06-28 16:55:06 +04:00
2776c41e0d wip: 🔕 temporary commit 2024-06-28 16:25:43 +04:00
42c4e7bf84 wip: 🔕 temporary commit 2024-06-27 19:29:54 +04:00
c80229ea05 wip: 🔕 temporary commit 2024-06-27 00:13:17 +04:00
f1bcd0d8c4 wip: 🔕 temporary commit 2024-06-26 23:08:07 +04:00
3505288e7a wip: 🔕 temporary commit 2024-06-26 22:35:48 +04:00
ea7dfe4870 wip: 🔕 temporary commit 2024-06-26 21:45:12 +04:00
6326e78cf4 wip: 🔕 temporary commit 2024-06-26 20:03:44 +04:00
3f22916cbb wip: 🔕 temporary commit 2024-06-26 17:59:19 +04:00
015e686635 wip: 🔕 temporary commit 2024-06-26 14:52:21 +04:00
2a1fa5c108 wip: 🔕 temporary commit 2024-06-26 01:06:02 +04:00
1682a3513f wip: 🔕 temporary commit 2024-06-25 22:26:34 +04:00
65fc5ecc7f wip: 🔕 temporary commit 2024-06-25 18:29:18 +04:00
c1eaee0287 wip: 🔕 temporary commit 2024-06-25 00:09:22 +04:00
ceeb7f9384 feat: Use slog instead of zap 2024-06-24 19:28:03 +04:00
a52dbde00c wip: 🔕 temporary commit 2024-06-23 23:48:51 +04:00
1b94bc367c wip: 🔕 temporary commit 2024-06-23 16:12:06 +04:00
669aaf6a1e wip: 🔕 temporary commit 2024-06-23 00:05:11 +04:00
b71475fcf7 wip: 🔕 temporary commit 2024-06-22 12:05:01 +04:00
71f8cfc162 wip: 🔕 temporary commit 2024-06-22 03:32:10 +04:00
e2193cd82e wip: 🔕 temporary commit 2024-06-21 17:13:27 +04:00
15d1bcf9c7 chore(docker): The docker env re-written 2024-06-20 23:09:32 +04:00
33 changed files with 477 additions and 997 deletions

View File

@ -1,18 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json",
"name": "default",
"image": "golang:1.22-bookworm",
"features": {
"ghcr.io/guiyomh/features/golangci-lint:0": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/sshd:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"streetsidesoftware.code-spell-checker"
]
}
},
"postCreateCommand": "go mod download"
}

View File

@ -44,16 +44,6 @@ jobs:
path: out/
if-no-files-found: error
retention-days: 1
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
working-directory: ./out
run: zip -r ./../templates.zip .
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: templates.zip
asset_name: error-pages-static.zip
tag: ${{ github.ref }}
demo:
name: Update the demo (GitHub Pages)
@ -86,7 +76,7 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile

View File

@ -83,7 +83,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- {uses: gacts/github-slug@v1, id: slug}
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile

View File

@ -87,8 +87,6 @@ WORKDIR /opt
# to find out which environment variables and CLI arguments are supported by the application, run the app
# with the `--help` flag or refer to the documentation at https://github.com/tarampampam/error-pages#readme
ENV LOG_LEVEL="warn"
# docs: https://docs.docker.com/reference/dockerfile/#healthcheck
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD [\
"/bin/error-pages", "--log-format", "json", "healthcheck" \

655
README.md
View File

@ -1,393 +1,3 @@
<p align="center">
<a href="https://github.com/tarampampam/error-pages#readme"><img src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark" alt="banner" width="100%" /></a>
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
</p>
One day, you might want to replace the standard error pages of your HTTP server or K8S cluster with something more
original and attractive. That's why this repository was created :) It contains:
- A simple error page generator written in Go
- Single-page error templates (themes) with various designs (located in the [templates](templates) directory) that
you can customize as you wish
- A fast and lightweight HTTP server is available as a single binary file and Docker image. It includes built-in error
page templates from this repository. You don't need anything except the compiled binary file or Docker image
- Pre-generated error pages (sources can be [found here][preview-sources], and the **demo** is always
accessible [here][preview-demo])
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
[preview-demo]:https://tarampampam.github.io/error-pages/
## 🔥 Features List
- HTTP server written in Go, utilizing the extremely fast [FastHTTP][fasthttp] and in-memory caching
- Respects the `Content-Type` HTTP header (and `X-Format`) value, responding with the corresponding format
(supported formats: `json`, `xml`, and `plaintext`)
- Logs written in `json` format
- Contains a health check endpoint (`/healthz`)
- Consumes very few resources and is suitable for use in resource-constrained environments
- Lightweight Docker image, distroless, and uses an unprivileged user by default
- [Go-template](https://pkg.go.dev/text/template) tags are allowed in the templates
- Ready for integration with [Traefik][traefik], [Ingress-nginx][ingress-nginx], and more
- Error pages can be embedded into your own Docker image with `nginx` in a few simple steps
- Fully configurable
- Distributed as a Docker image and compiled binary files
- Localized HTML error pages (🇺🇸, 🇫🇷, 🇺🇦, 🇷🇺, 🇵🇹, 🇳🇱, 🇩🇪, 🇪🇸, 🇨🇳, 🇮🇩, 🇵🇱) - translation process
[described here](l10n) - other translations are welcome!
[fasthttp]:https://github.com/valyala/fasthttp
[traefik]:https://github.com/traefik/traefik
## 🧩 Install
Download the latest binary file for your OS/architecture from the [releases page][latest-release] or use our Docker image:
| Registry | Image |
|-----------------------------------|-----------------------------------|
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
| [Docker Hub][docker-hub] (mirror) | `tarampampam/error-pages` |
> [!IMPORTANT]
> Using the `latest` tag for the Docker image is highly discouraged due to potential backward-incompatible changes
> during **major** upgrades. Please use tags in the `X.Y.Z` format.
💣 **Or** you can also download the **already rendered** error pages pack as a [zip][pages-pack-zip] or
[tar.gz][pages-pack-tar-gz] archive.
[latest-release]:https://github.com/tarampampam/error-pages/releases/latest
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
[ghcr]:https://github.com/tarampampam/error-pages/pkgs/container/error-pages
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
## 🛠 Usage scenarios
### HTTP server starting, utilizing either a binary file or Docker image
First, ensure you have a precompiled binary file on your machine or have Docker/Podman installed. Next, start the
server with the following command:
```bash
./error-pages serve
# or
docker run --rm -p '8080:8080/tcp' tarampampam/error-pages serve
```
That's it! The server will begin running and listen on address `0.0.0.0` and port `8080`. Access error pages using
URLs like `http://127.0.0.1:8080/{page_code}.html`.
To retrieve different error page codes using a static URL, use the `X-Code` HTTP header:
```bash
curl -H 'X-Code: 500' http://127.0.0.1:8080/
```
The server respects the `Content-Type` HTTP header (and `X-Format`), delivering responses in requested formats
such as HTML, XML, JSON, and PlainText. Customization of these formats is possible via CLI flags or environment
variables.
For integration with [ingress-nginx][ingress-nginx] or debugging purposes, start the server with `--show-details`
(or set the environment variable `SHOW_DETAILS=true`) to enrich error pages (including JSON and XML responses)
with upstream proxy information.
Switch themes using the `TEMPLATE_NAME` environment variable or the `--template-name` flag; available templates
are detailed in the readme file below.
> [!TIP]
> Use the `--rotation-mode` flag or the `TEMPLATES_ROTATION_MODE` environment variable to automate theme
> rotation. Available modes include `random-on-startup`, `random-on-each-request`, `random-hourly`,
> and `random-daily`.
To proxy HTTP headers from requests to responses, utilize the `--proxy-headers` flag or environment variable
(comma-separated list of headers).
<details>
<summary><strong>🚀 Generate a set of error pages using built-in or my own template</strong></summary>
Generating a set of error pages is straightforward. If you prefer to use your own template, start by crafting it.
Create a file like this:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ code }}</title>
</head>
<body>
<h1>{{ message }}: {{ description }}</h1>
</body>
</html>
```
Save it as `my-template.html` and use it as your custom template. Then, generate your error pages using the command:
```bash
mkdir -p /path/to/output
./error-pages build --add-template /path/to/your/my-template.html --target-dir /path/to/output
```
This will create error pages based on your template in the specified output directory:
```bash
$ cd /path/to/output && tree .
├── my-template
│ ├── 400.html
│ ├── 401.html
│ ├── 403.html
│ ├── 404.html
│ ├── 405.html
│ ├── 407.html
│ ├── 408.html
│ ├── 409.html
│ ├── 410.html
│ ├── 411.html
│ ├── 412.html
│ ├── 413.html
│ ├── 416.html
│ ├── 418.html
│ ├── 429.html
│ ├── 500.html
│ ├── 502.html
│ ├── 503.html
│ ├── 504.html
│ └── 505.html
$ cat my-template/403.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>403</title>
</head>
<body>
<h1>Forbidden: Access is forbidden to the requested page</h1>
</body>
</html>
```
</details>
<details>
<summary><strong>🚀 Customize error pages within your own Nginx Docker image</strong></summary>
To create this cocktail, we need two components:
- Nginx configuration file
- A Dockerfile to build the image
Let's start with the Nginx configuration file:
```nginx
# File: nginx.conf
server {
listen 80;
server_name localhost;
error_page 401 /_error-pages/401.html;
error_page 403 /_error-pages/403.html;
error_page 404 /_error-pages/404.html;
error_page 500 /_error-pages/500.html;
error_page 502 /_error-pages/502.html;
error_page 503 /_error-pages/503.html;
location ^~ /_error-pages/ {
internal;
root /usr/share/nginx/errorpages;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
}
```
And the Dockerfile:
```dockerfile
FROM docker.io/library/nginx:1.27-alpine
# override default Nginx configuration
COPY --chown=nginx ./nginx.conf /etc/nginx/conf.d/default.conf
# copy statically built error pages from the error-pages image
# (instead of `ghost` you may use any other template)
COPY --chown=nginx \
--from=ghcr.io/tarampampam/error-pages:3 \
/opt/html/ghost /usr/share/nginx/errorpages/_error-pages
```
Now, we can build the image:
```bash
docker build --tag your-nginx:local -f ./Dockerfile .
```
And voilà! Let's start the image and test if everything is working as expected:
```bash
docker run --rm -p '8081:80/tcp' your-nginx:local
curl http://127.0.0.1:8081/foobar | head -n 15 # in another terminal
```
</details>
<details>
<summary><strong>🚀 Usage with Traefik and local Docker Compose</strong></summary>
Instead of thousands of words, let's take a look at one compose file:
```yaml
# file: compose.yml (or docker-compose.yml)
services:
traefik:
image: docker.io/library/traefik:v3.1
command:
#- --log.level=DEBUG
- --api.dashboard=true # activate dashboard
- --api.insecure=true # enable the API in insecure mode
- --providers.docker=true # enable Docker backend with default settings
- --providers.docker.exposedbydefault=false # do not expose containers by default
- --entrypoints.web.address=:80 # --entrypoints.<name>.address for ports, 80 (i.e., name = web)
ports:
- "80:80/tcp" # HTTP (web)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
traefik.enable: true
# dashboard
traefik.http.routers.traefik.rule: Host(`traefik.localtest.me`)
traefik.http.routers.traefik.service: api@internal
traefik.http.routers.traefik.entrypoints: web
traefik.http.routers.traefik.middlewares: error-pages-middleware
depends_on:
error-pages: {condition: service_healthy}
error-pages:
image: ghcr.io/tarampampam/error-pages:3 # using the latest tag is highly discouraged
environment:
TEMPLATE_NAME: l7 # set the error pages template
labels:
traefik.enable: true
# use as "fallback" for any NON-registered services (with priority below normal)
traefik.http.routers.error-pages-router.rule: HostRegexp(`.+`)
traefik.http.routers.error-pages-router.priority: 10
# should say that all of your services work on https
traefik.http.routers.error-pages-router.entrypoints: web
traefik.http.routers.error-pages-router.middlewares: error-pages-middleware
# "errors" middleware settings
traefik.http.middlewares.error-pages-middleware.errors.status: 400-599
traefik.http.middlewares.error-pages-middleware.errors.service: error-pages-service
traefik.http.middlewares.error-pages-middleware.errors.query: /{status}.html
# define service properties
traefik.http.services.error-pages-service.loadbalancer.server.port: 8080
nginx-or-any-another-service:
image: docker.io/library/nginx:1.27-alpine
labels:
traefik.enable: true
traefik.http.routers.test-service.rule: Host(`test.localtest.me`)
traefik.http.routers.test-service.entrypoints: web
traefik.http.routers.test-service.middlewares: error-pages-middleware
```
After executing `docker compose up` in the same directory as the `compose.yml` file, you can:
- Open the Traefik dashboard [at `traefik.localtest.me`](http://traefik.localtest.me/dashboard/#/)
- [View customized error pages on the Traefik dashboard](http://traefik.localtest.me/foobar404)
- Open the nginx index page [at `test.localtest.me`](http://test.localtest.me/)
- View customized error pages for non-existent [pages](http://test.localtest.me/404) and [domains](http://404.localtest.me/)
Isn't this kind of magic? 😀
</details>
<details>
<summary><strong>🚀 Kubernetes (K8s) & Ingress Nginx</strong></summary>
Error-pages can be configured to work with the [ingress-nginx][ingress-nginx] helm chart in Kubernetes.
- Set the `custom-http-errors` config value
- Enable default backend
- Set the default backend image
```yaml
controller:
config:
custom-http-errors: >-
401,403,404,500,501,502,503
defaultBackend:
enabled: true
image:
repository: ghcr.io/tarampampam/error-pages
tag: '3' # using the latest tag is highly discouraged
extraEnvs:
- name: TEMPLATE_NAME # Optional: change the default theme
value: l7
- name: SHOW_DETAILS # Optional: enables the output of additional information on error pages
value: 'true'
```
</details>
## 🦾 Performance
Hardware used:
- 12th Gen Intel® Core™ i7-1260P (16 cores)
- 32 GiB RAM
RPS: **~180k** 🔥 requests served without any errors, with peak memory usage ~60 MiB under the default configuration
<details>
<summary>Performance test details (click to expand)</summary>
```shell
$ ulimit -aH | grep file
core file size (blocks, -c) unlimited
file size (blocks, -f) unlimited
open files (-n) 1048576
file locks (-x) unlimited
$ go build ./cmd/error-pages/ && ./error-pages --log-level warn serve
$ ./error-pages perftest # in separate terminal
Starting the test to bomb ONE PAGE (code). Please, be patient...
Test completed successfully. Here is the output:
Running 15s test @ http://127.0.0.1:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.54ms 4.90ms 74.57ms 86.55%
Req/Sec 16.47k 2.89k 38.11k 69.46%
2967567 requests in 15.09s, 44.70GB read
Requests/sec: 196596.49
Transfer/sec: 2.96GB
Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...
Test completed successfully. Here is the output:
Running 15s test @ http://127.0.0.1:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.25ms 6.03ms 74.23ms 86.97%
Req/Sec 14.29k 2.75k 32.16k 69.63%
2563245 requests in 15.07s, 38.47GB read
Requests/sec: 170062.69
Transfer/sec: 2.55GB
```
</details>
<!--GENERATED:CLI_DOCS-->
<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->
## CLI interface
@ -402,12 +12,12 @@ Global flags:
| Name | Description | Default value | Environment variables |
|--------------------|---------------------------------------|:-------------:|:---------------------:|
| `--log-level="…"` | Logging level (debug/info/warn/error) | `info` | `LOG_LEVEL` |
| `--log-format="…"` | Logging format (console/json) | `console` | `LOG_FORMAT` |
| `--log-level="…"` | logging level (debug/info/warn/error) | `info` | `LOG_LEVEL` |
| `--log-format="…"` | logging format (console/json) | `console` | `LOG_FORMAT` |
### `serve` command (aliases: `s`, `server`, `http`)
Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D.
Start HTTP server.
Usage:
@ -417,24 +27,23 @@ $ error-pages [GLOBAL FLAGS] serve [COMMAND FLAGS] [ARGUMENTS...]
The following flags are supported:
| Name | Description | Default value | Environment variables |
|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:---------------------------:|
| `--listen="…"` (`-l`) | The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP) | `0.0.0.0` | `LISTEN_ADDR` |
| `--port="…"` (`-p`) | The TCP port number for the HTTP server to listen on (0-65535) | `8080` | `LISTEN_PORT` |
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
| `--json-format="…"` | Override the default error page response in JSON format (Go templates are supported; the error page will use this template if the client requests JSON content type) | | `RESPONSE_JSON_FORMAT` |
| `--xml-format="…"` | Override the default error page response in XML format (Go templates are supported; the error page will use this template if the client requests XML content type) | | `RESPONSE_XML_FORMAT` |
| `--plaintext-format="…"` | Override the default error page response in plain text format (Go templates are supported; the error page will use this template if the client requests plain text content type or does not specify any) | | `RESPONSE_PLAINTEXT_FORMAT` |
| `--template-name="…"` (`-t`) | Name of the template to use for rendering error pages (built-in templates: app-down, cats, connection, ghost, hacker-terminal, l7, lost-in-space, noise, orient, shuffle) | `app-down` | `TEMPLATE_NAME` |
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
| `--default-error-page="…"` | The code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` |
| `--send-same-http-code` | The HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` |
| `--show-details` | Show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` |
| `--proxy-headers="…"` | HTTP headers listed here will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` |
| `--rotation-mode="…"` | Templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily) | `disabled` | `TEMPLATES_ROTATION_MODE` |
| `--read-buffer-size="…"` | Per-connection buffer size in bytes for reading requests, this also limits the maximum header size (increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., large cookies), note that increasing this value will increase memory consumption) | `5120` | `READ_BUFFER_SIZE` |
| Name | Description | Default value | Environment variables |
|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:---------------------------:|
| `--listen="…"` (`-l`) | the HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP) | `0.0.0.0` | `LISTEN_ADDR` |
| `--port="…"` (`-p`) | the TCP port number for the HTTP server to listen on (0-65535) | `8080` | `LISTEN_PORT` |
| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
| `--disable-template="…"` | disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
| `--add-http-code="…"` (`--add-code`) | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
| `--json-format="…"` | override the default error page response in JSON format (Go templates are supported; the error page will use this template if the client requests JSON content type) | | `RESPONSE_JSON_FORMAT` |
| `--xml-format="…"` | override the default error page response in XML format (Go templates are supported; the error page will use this template if the client requests XML content type) | | `RESPONSE_XML_FORMAT` |
| `--plaintext-format="…"` | override the default error page response in plain text format (Go templates are supported; the error page will use this template if the client requests plain text content type or does not specify any) | | `RESPONSE_PLAINTEXT_FORMAT` |
| `--template-name="…"` (`-t`) | name of the template to use for rendering error pages (built-in templates: app-down, cats, connection, ghost, hacker-terminal, l7, lost-in-space, noise, orient, shuffle) | `app-down` | `TEMPLATE_NAME` |
| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
| `--default-error-page="…"` | the code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` |
| `--send-same-http-code` | the HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` |
| `--show-details` | show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` |
| `--proxy-headers="…"` | HTTP headers listed here will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` |
| `--rotation-mode="…"` | templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily) | `disabled` | `TEMPLATES_ROTATION_MODE` |
### `build` command (aliases: `b`)
@ -450,12 +59,12 @@ The following flags are supported:
| Name | Description | Default value | Environment variables |
|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------:|:---------------------:|
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
| `--index` (`-i`) | Generate index.html file with links to all error pages | `false` | *none* |
| `--target-dir="…"` (`--out`, `--dir`, `-o`) | Directory to put the built error pages into | `.` | *none* |
| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
| `--disable-template="…"` | disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
| `--add-http-code="…"` (`--add-code`) | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
| `--index` (`-i`) | generate index.html file with links to all error pages | `false` | *none* |
| `--target-dir="…"` (`--out`, `--dir`, `-o`) | directory to put the built error pages into | `.` | *none* |
### `healthcheck` command (aliases: `chk`, `health`, `check`)
@ -479,10 +88,6 @@ The following flags are supported:
The following templates are built-in and available for use without any additional setup:
> [!NOTE]
> The `cats` template is the only one of those that fetches resources (the actual cat pictures) from external
> servers - all other templates are self-contained.
<table>
<thead>
<tr>
@ -618,6 +223,176 @@ The following templates are built-in and available for use without any additiona
> everyone at [error-pages.goatcounter.com](https://error-pages.goatcounter.com/). This is simply a counter to display
> how often a particular template is used, nothing more.
<!--
<p align="center">
<a href="https://github.com/tarampampam/error-pages#readme"><img src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark" alt="banner" width="100%" /></a>
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://codecov.io/gh/tarampampam/error-pages"><img src="https://img.shields.io/codecov/c/github/tarampampam/error-pages/master.svg?maxAge=30&label=&logo=codecov&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
</p>
<p align="center"><sup>
22 feb. 2022 - ⚡ Our Docker image was downloaded <strong>one MILLION times</strong> from the docker hub! ⚡<br/>
10 apr. 2023 - ⚡ <strong>Two million times</strong> from the docker hub and <strong>one million</strong> from the ghcr! ⚡
</sup></p>
One day you may want to replace the standard error pages of your HTTP server with something more original and pretty. That's what this repository was created for :) It contains:
- Simple error pages generator, written in Go
- Single-page error page templates with different designs (located in the [templates](https://github.com/tarampampam/error-pages/tree/master/templates) directory)
- Fast and lightweight HTTP server
- Already generated error pages (sources can be [found here][preview-sources], the **demonstration** is always accessible [here][preview-demo])
## 🔥 Features list
- HTTP server written in Go, with the extremely fast [FastHTTP][fasthttp] under the hood
- Respects the `Content-Type` HTTP header (and `X-Format`) value and responds with the corresponding format (supported formats are `json` and `xml`)
- Writes logs in `json` format
- Contains healthcheck endpoint (`/healthz`)
- Contains metrics endpoint (`/metrics`) in Prometheus format
- Lightweight docker image _(~4.6Mb compressed size)_, distroless and uses the unleveled user by default
- [Go-template](https://pkg.go.dev/text/template) tags are allowed in the templates
- Ready for integration with [Traefik][traefik] ([error pages customization](https://doc.traefik.io/traefik/middlewares/http/errorpages/)) and [Ingress-nginx][ingress-nginx]
- Error pages can be [embedded into your own `nginx`][wiki-usage-with-nginx] docker image
- Fully configurable (take a look at the [configuration file](https://github.com/tarampampam/error-pages/blob/master/error-pages.yml) and [project Wiki][wiki])
- Distributed using docker image and compiled binary files
- Localized (🇺🇸, 🇫🇷, 🇺🇦, 🇷🇺, 🇵🇹, 🇳🇱, 🇩🇪, 🇪🇸, 🇨🇳, 🇮🇩, 🇵🇱) HTML error pages (translation process [described here](https://github.com/tarampampam/error-pages/tree/master/l10n) - other translations are welcome!)
## 🧩 Install
Download the latest binary file for your os/arch from the [releases page][releases] or use our docker image:
| Registry | Image |
|-----------------------------------|-----------------------------------|
| [Docker Hub][docker-hub] | `tarampampam/error-pages` |
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
> Using the `latest` tag for the docker image is highly discouraged because of possible backward-incompatible changes during **major** upgrades. Please, use tags in `X.Y.Z` format
💣 **Or** you can download **already rendered** error pages pack as a [zip][pages-pack-zip] or [tar.gz][pages-pack-tar-gz] archive.
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
## 🛠 Usage
Please, take a look at [our Wiki][wiki] for the common usage stories:
- [HTTP server][wiki-http-server] (routes, formats, flags and environment variables)
- [Pages generator][wiki-generator] (build your own error page set)
- [Static error pages][wiki-static-error-pages] (extract generated static error pages from the docker image)
- [Usage with nginx][wiki-usage-with-nginx] (include our error pages into an image with nginx)
- [Usage with Traefik and local Docker Compose][wiki-traefik-docker-compose] (it's a good starting point for the tests)
- [Usage with Traefik and Docker Swarm][wiki-traefik-swarm]
- [Kubernetes & ingress nginx][wiki-k8s-ingress-nginx]
[wiki]:https://github.com/tarampampam/error-pages/wiki
[wiki-http-server]:https://github.com/tarampampam/error-pages/wiki/HTTP-server
[wiki-generator]:https://github.com/tarampampam/error-pages/wiki/Generator
[wiki-static-error-pages]:https://github.com/tarampampam/error-pages/wiki/Static-error-pages
[wiki-usage-with-nginx]:https://github.com/tarampampam/error-pages/wiki/Usage-with-nginx
[wiki-traefik-swarm]:https://github.com/tarampampam/error-pages/wiki/Traefik-(docker-swarm)
[wiki-traefik-docker-compose]:https://github.com/tarampampam/error-pages/wiki/Traefik-(docker-compose)
[wiki-k8s-ingress-nginx]:https://github.com/tarampampam/error-pages/wiki/Kubernetes-&-ingress-nginx
## 🦾 Performance
Used hardware:
- Intel® Core™ i7-10510U CPU @ 1.80GHz × 8
- 16 GiB RAM
```shell
$ ulimit -aH | grep file
-f: file size (blocks) unlimited
-c: core file size (blocks) unlimited
-n: file descriptors 1048576
-x: file locks unlimited
$ docker run --rm -p "8080:8080/tcp" -e "SHOW_DETAILS=true" error-pages:local # in separate terminal
$ wrk --timeout 1s -t12 -c400 -d30s -s ./test/wrk/request.lua http://127.0.0.1:8080/
Running 30s test @ http://127.0.0.1:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 10.84ms 7.89ms 135.91ms 79.36%
Req/Sec 3.23k 785.11 6.30k 70.04%
1160567 requests in 30.10s, 4.12GB read
Requests/sec: 38552.04
Transfer/sec: 140.23MB
```
<details>
<summary>FS & memory usage stats during the test</summary>
<p align="center">
<img src="https://hsto.org/webt/ts/w-/lz/tsw-lznvru0ngjneiimkwq7ysyc.png" alt="" />
</p>
</details>
## 🪂 Templates
| Name | Preview |
|:-----------------:|:------------------------------------------------------------------:|
| `ghost` | [![ghost][ghost-screen]][ghost-link] |
| `l7-light` | [![l7-light][l7-light-screen]][l7-light-link] |
| `l7-dark` | [![l7-dark][l7-dark-screen]][l7-dark-link] |
| `shuffle` | [![shuffle][shuffle-screen]][shuffle-link] |
| `noise` | [![noise][noise-screen]][noise-link] |
| `hacker-terminal` | [![hacker-terminal][hacker-terminal-screen]][hacker-terminal-link] |
| `cats` | [![cats][cats-screen]][cats-link] |
| `lost-in-space` | [![lost-in-space][lost-in-space-screen]][lost-in-space-link] |
| `app-down` | [![app-down][app-down-screen]][app-down-link] |
| `connection` | [![connection][connection-screen]][connection-link] |
| `matrix` | [![matrix][matrix-screen]][matrix-link] |
| `orient` | [![orient][orient-screen]][orient-link] |
> Note: `noise` template highly uses the CPU, be careful
[ghost-screen]:https://hsto.org/webt/oj/cl/4k/ojcl4ko_cvusy5xuki6efffzsyo.gif
[ghost-link]:https://tarampampam.github.io/error-pages/ghost/404.html
[l7-light-screen]:https://hsto.org/webt/hx/ca/mm/hxcammfm7qjmogtvsjxcidgf7c8.png
[l7-light-link]:https://tarampampam.github.io/error-pages/l7-light/404.html
[l7-dark-screen]:https://hsto.org/webt/s1/ih/yr/s1ihyrqs_y-sgraoimfhk6ypney.png
[l7-dark-link]:https://tarampampam.github.io/error-pages/l7-dark/404.html
[shuffle-screen]:https://hsto.org/webt/7w/rk/3m/7wrk3mrzz3y8qfqwovmuvacu-bs.gif
[shuffle-link]:https://tarampampam.github.io/error-pages/shuffle/404.html
[noise-screen]:https://hsto.org/webt/42/oq/8y/42oq8yok_i-arrafjt6hds_7ahy.gif
[noise-link]:https://tarampampam.github.io/error-pages/noise/404.html
[hacker-terminal-screen]:https://hsto.org/webt/5s/l0/p1/5sl0p1_ud_nalzjzsj5slz6dfda.gif
[hacker-terminal-link]:https://tarampampam.github.io/error-pages/hacker-terminal/404.html
[cats-screen]:https://hsto.org/webt/_g/y-/ke/_gy-keqinz-3867jbw36v37-iwe.jpeg
[cats-link]:https://tarampampam.github.io/error-pages/cats/404.html
[lost-in-space-screen]:https://hsto.org/webt/lf/ln/x8/lflnx8fuy4rofxju34ttskijdsu.gif
[lost-in-space-link]:https://tarampampam.github.io/error-pages/lost-in-space/404.html
[app-down-screen]:https://habrastorage.org/webt/j2/la/fj/j2lafjvu_xjflzrvhiixobxy_ca.png
[app-down-link]:https://tarampampam.github.io/error-pages/app-down/404.html
[connection-screen]:https://hsto.org/webt/x4/ah/jb/x4ahjboo4-arm3bxpaash_sflmw.png
[connection-link]:https://tarampampam.github.io/error-pages/connection/404.html
[matrix-screen]:https://hsto.org/webt/ng/tf/oi/ngtfoiolvmq6hf15kimcxmhprhk.gif
[matrix-link]:https://tarampampam.github.io/error-pages/matrix/404.html
[orient-screen]:https://hsto.org/webt/pz/eu/v_/pzeuv_lyeqr0xpusa4zfrtgk7sa.png
[orient-link]:https://tarampampam.github.io/error-pages/orient/404.html
## 🦾 Contributors
I want to say a big thank you to everyone who contributed to this project:
@ -626,23 +401,45 @@ I want to say a big thank you to everyone who contributed to this project:
[contributors]:https://github.com/tarampampam/error-pages/graphs/contributors
## 📰 Changes log
[![Release date][badge-release-date]][releases]
[![Commits since latest release][badge-commits]][commits]
Changes log can be [found here][changelog].
## 👾 Support
[![Issues][badge-issues]][issues]
[![Issues][badge-prs]][prs]
If you encounter any bugs in the project, please [create an issue][new-issue] in this repository.
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
[issues]:https://github.com/tarampampam/error-pages/issues
[prs]:https://github.com/tarampampam/error-pages/pulls
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
If you find any bugs in the project, please [create an issue][new-issue] in the current repository.
## 📖 License
This is open-sourced software licensed under the [MIT License][license].
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
[badge-release]:https://img.shields.io/github/release/tarampampam/error-pages.svg?maxAge=30
[badge-release-date]:https://img.shields.io/github/release-date/tarampampam/error-pages.svg?maxAge=180
[badge-commits]:https://img.shields.io/github/commits-since/tarampampam/error-pages/latest.svg?maxAge=45
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
[docker-hub-tags]:https://hub.docker.com/r/tarampampam/error-pages/tags
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
[releases]:https://github.com/tarampampam/error-pages/releases
[commits]:https://github.com/tarampampam/error-pages/commits
[changelog]:https://github.com/tarampampam/error-pages/blob/master/CHANGELOG.md
[issues]:https://github.com/tarampampam/error-pages/issues
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
[prs]:https://github.com/tarampampam/error-pages/pulls
[ghcr]:https://github.com/users/tarampampam/packages/container/package/error-pages
[fasthttp]:https://github.com/valyala/fasthttp
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
[preview-demo]:https://tarampampam.github.io/error-pages/
[traefik]:https://github.com/traefik/traefik
[ingress-nginx]:https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx
-->

2
go.mod
View File

@ -6,7 +6,6 @@ require (
github.com/stretchr/testify v1.9.0
github.com/urfave/cli-docs/v3 v3.0.0-alpha5
github.com/urfave/cli/v3 v3.0.0-alpha9
github.com/valyala/fasthttp v1.55.0
go.uber.org/automaxprocs v1.5.3
)
@ -20,6 +19,7 @@ require (
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@ -25,7 +25,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
logLevelFlag = cli.StringFlag{
Name: "log-level",
Value: logger.InfoLevel.String(),
Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_LEVEL"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
@ -41,7 +41,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
logFormatFlag = cli.StringFlag{
Name: "log-format",
Value: logger.ConsoleFormat.String(),
Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
@ -80,7 +80,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
serve.NewCommand(log),
build.NewCommand(log),
healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()),
perftest.NewCommand(),
perftest.NewCommand(log),
},
Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()),
Flags: []cli.Flag{ // global flags

View File

@ -44,18 +44,16 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
addCodeFlag = shared.AddHTTPCodesFlag
disableL10nFlag = shared.DisableL10nFlag
createIndexFlag = cli.BoolFlag{
Name: "index",
Aliases: []string{"i"},
Usage: "Generate index.html file with links to all error pages",
Category: shared.CategoryBuild,
Name: "index",
Aliases: []string{"i"},
Usage: "generate index.html file with links to all error pages",
}
targetDirFlag = cli.StringFlag{
Name: "target-dir",
Aliases: []string{"out", "dir", "o"},
Usage: "Directory to put the built error pages into",
Usage: "directory to put the built error pages into",
Value: ".", // current directory by default
Config: cli.StringConfig{TrimSpace: true},
Category: shared.CategoryBuild,
OnlyOnce: true,
Validator: func(dir string) error {
if dir == "" {

View File

@ -1,59 +1,32 @@
package perftest
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"math/rand"
"net/http"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/logger"
)
const wrkOneCodeTestLua = `
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
request = function()
wrk.headers["User-Agent"] = "wrk"
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
return wrk.format("GET", "/500.html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
end
`
//nolint:lll
const bombDifferentCodes = `
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
request = function()
wrk.headers["User-Agent"] = "wrk"
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
return wrk.format("GET", "/" .. tostring(math.random(400, 599)) .. ".html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
end
`
// NewCommand creates `perftest` command.
func NewCommand() *cli.Command { //nolint:funlen
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
var (
portFlag = shared.ListenPortFlag
durationFlag = cli.DurationFlag{
Name: "duration",
Aliases: []string{"d"},
Usage: "Duration of test",
Value: 15 * time.Second, //nolint:mnd
Usage: "duration of the test",
Value: 10 * time.Second, //nolint:mnd
Validator: func(d time.Duration) error {
if d <= time.Second {
return errors.New("duration can't be less than 1 second")
@ -65,28 +38,11 @@ func NewCommand() *cli.Command { //nolint:funlen
threadsFlag = cli.UintFlag{
Name: "threads",
Aliases: []string{"t"},
Usage: "Number of threads to use",
Value: max(2, uint64(math.Round(float64(runtime.NumCPU())/1.3))), //nolint:mnd
Usage: "number of threads",
Value: max(2, uint64(runtime.NumCPU()/2)), //nolint:mnd
Validator: func(u uint64) error {
if u == 0 {
return errors.New("threads number can't be zero")
} else if u > math.MaxUint16 {
return errors.New("threads number can't be greater than 65535")
}
return nil
},
}
connectionsFlag = cli.UintFlag{
Name: "connections",
Aliases: []string{"c"},
Usage: "Number of connections to keep open",
Value: max(16, uint64(runtime.NumCPU()*25)), //nolint:mnd
Validator: func(u uint64) error {
if u == 0 {
return errors.New("threads number can't be zero")
} else if u > math.MaxUint16 {
return errors.New("threads number can't be greater than 65535")
}
return nil
@ -96,47 +52,97 @@ func NewCommand() *cli.Command { //nolint:funlen
return &cli.Command{
Name: "perftest",
Aliases: []string{"perf", "benchmark", "bench"},
Aliases: []string{"perf", "test"},
Hidden: true,
Usage: "Performance (load) test for the HTTP server (locally installed wrk is required)",
Action: func(ctx context.Context, c *cli.Command) error {
var wrkBinPath, lErr = exec.LookPath("wrk")
if lErr != nil {
return fmt.Errorf("seems like wrk (https://github.com/wg/wrk) is not installed: %w", lErr)
Usage: "Simple performance (load) test for the HTTP server",
Action: func(ctx context.Context, c *cli.Command) error { // TODO: use fasthttp.Client
var (
perfCtx, cancel = context.WithTimeout(ctx, c.Duration(durationFlag.Name))
startedAt = time.Now()
wg sync.WaitGroup
success atomic.Uint64
failed atomic.Uint64
)
defer func() {
cancel()
log.Info("Summary",
logger.Uint64("success", success.Load()),
logger.Uint64("failed", failed.Load()),
logger.Duration("duration", time.Since(startedAt)),
logger.Float64("RPS", float64(success.Load()+failed.Load())/time.Since(startedAt).Seconds()),
logger.Float64("errors rate", float64(failed.Load())/float64(success.Load()+failed.Load())*100), //nolint:mnd
)
}()
log.Info("Running test",
logger.Uint64("threads", c.Uint(threadsFlag.Name)),
logger.Duration("duration", c.Duration(durationFlag.Name)),
)
var httpClient = &http.Client{
Transport: &http.Transport{MaxConnsPerHost: max(2, int(c.Uint(threadsFlag.Name))-1)}, //nolint:mnd
Timeout: c.Duration(durationFlag.Name) + time.Second,
}
var runTest = func(scriptContent string) error {
if stdOut, stdErr, err := wrkRunTest(ctx,
wrkBinPath,
uint16(c.Uint(threadsFlag.Name)),
uint16(c.Uint(connectionsFlag.Name)),
c.Duration(durationFlag.Name),
uint16(c.Uint(portFlag.Name)),
scriptContent,
); err != nil {
var errData, _ = io.ReadAll(stdErr)
for i := uint64(0); i < c.Uint(threadsFlag.Name); i++ {
wg.Add(1)
return fmt.Errorf("failed to execute the test: %w (%s)", err, string(errData))
} else {
var outData, _ = io.ReadAll(stdOut)
go func(log *logger.Logger) {
defer wg.Done()
printf("Test completed successfully. Here is the output:\n\n%s\n", string(outData))
}
if perfCtx.Err() != nil {
return
}
return nil
var req, rErr = makeRequest(perfCtx, uint16(c.Uint(portFlag.Name)))
if rErr != nil {
log.Error("Failed to create a new request", logger.Error(rErr))
return
}
for {
var sentAt = time.Now()
var resp, respErr = httpClient.Do(req)
if resp != nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil && !errIsDone(err) {
log.Error("Failed to read response body", logger.Error(err))
}
if err := resp.Body.Close(); err != nil && !errIsDone(err) {
log.Error("Failed to close response body", logger.Error(err))
}
}
if respErr != nil {
if errIsDone(respErr) {
return
}
log.Error("Request failed", logger.Error(respErr))
failed.Add(1)
continue
}
log.Debug("Response received",
logger.String("status", resp.Status),
logger.Duration("duration", time.Since(sentAt)),
logger.Int64("size", resp.ContentLength),
logger.Uint64("success", success.Load()),
logger.Uint64("failed", failed.Load()),
)
success.Add(1)
}
}(log.Named(fmt.Sprintf("thread-%d", i)))
}
printf("Starting the test to bomb ONE PAGE (code). Please, be patient...\n")
if err := runTest(wrkOneCodeTestLua); err != nil {
return err
}
printf("Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...\n")
if err := runTest(bombDifferentCodes); err != nil {
return err
}
wg.Wait()
return nil
},
@ -144,51 +150,54 @@ func NewCommand() *cli.Command { //nolint:funlen
&portFlag,
&durationFlag,
&threadsFlag,
&connectionsFlag,
},
}
}
func printf(format string, args ...any) { fmt.Printf(format, args...) } //nolint:forbidigo
// randomIntBetween returns a random integer between min and max.
func randomIntBetween(min, max int) int { return min + rand.Intn(max-min) } //nolint:gosec
func wrkRunTest(
ctx context.Context,
wrkBinPath string,
threadsCount, connectionsCount uint16,
duration time.Duration,
port uint16,
scriptContent string,
) (io.Reader, io.Reader, error) {
var tmpFile, tErr = os.CreateTemp("", "ep-perf-one-page")
if tErr != nil {
return nil, nil, fmt.Errorf("failed to create a temporary file: %w", tErr)
}
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
}()
if _, err := tmpFile.WriteString(scriptContent); err != nil {
return nil, nil, fmt.Errorf("failed to write to a temporary file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return nil, nil, err
}
var stdout, stderr bytes.Buffer
var cmd = exec.CommandContext(ctx, wrkBinPath, //nolint:gosec
"--timeout", "1s",
"--threads", strconv.FormatUint(uint64(threadsCount), 10),
"--connections", strconv.FormatUint(uint64(connectionsCount), 10),
"--duration", duration.String(),
"--script", tmpFile.Name(),
fmt.Sprintf("http://127.0.0.1:%d/", port),
// makeRequest creates a new HTTP request for the performance test.
func makeRequest(ctx context.Context, port uint16) (*http.Request, error) {
var req, rErr = http.NewRequestWithContext(ctx,
http.MethodGet,
fmt.Sprintf(
"http://127.0.0.1:%d/%d.html?rnd=%d", // for load testing purposes only
port,
randomIntBetween(400, 418), //nolint:mnd
randomIntBetween(1, 999_999_999), //nolint:mnd
),
http.NoBody,
)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if rErr != nil {
return nil, rErr
}
return &stdout, &stderr, cmd.Run() // execute
req.Header.Set("Connection", "keep-alive")
req.Header.Set("User-Agent", "perftest")
req.Header.Set("X-Namespace", fmt.Sprintf("namespace-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd
req.Header.Set("X-Request-ID", fmt.Sprintf("req-id-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd
var contentType string
switch randomIntBetween(1, 4) { //nolint:mnd
case 1:
contentType = "application/json"
case 2: //nolint:mnd
contentType = "application/xml"
case 3: //nolint:mnd
contentType = "text/html"
default:
contentType = "text/plain"
}
req.Header.Set("Content-Type", contentType)
return req, nil
}
// errIsDone checks if the error is a context.DeadlineExceeded or context.Canceled.
func errIsDone(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}

View File

@ -22,9 +22,9 @@ type command struct {
opt struct {
http struct { // our HTTP server
addr string
port uint16
readBufferSize uint
addr string
port uint16
// readBufferSize uint
}
}
}
@ -46,28 +46,25 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
disableL10nFlag = shared.DisableL10nFlag
jsonFormatFlag = cli.StringFlag{
Name: "json-format",
Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
Usage: "override the default error page response in JSON format (Go templates are supported; the error " +
"page will use this template if the client requests JSON content type)",
Sources: env("RESPONSE_JSON_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
xmlFormatFlag = cli.StringFlag{
Name: "xml-format",
Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
Usage: "override the default error page response in XML format (Go templates are supported; the error " +
"page will use this template if the client requests XML content type)",
Sources: env("RESPONSE_XML_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
plainTextFormatFlag = cli.StringFlag{
Name: "plaintext-format",
Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
Usage: "override the default error page response in plain text format (Go templates are supported; the " +
"error page will use this template if the client requests plain text content type or does not specify any)",
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
@ -75,19 +72,17 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
Name: "template-name",
Aliases: []string{"t"},
Value: cfg.TemplateName,
Usage: "Name of the template to use for rendering error pages (built-in templates: " +
Usage: "name of the template to use for rendering error pages (built-in templates: " +
strings.Join(cfg.Templates.Names(), ", ") + ")",
Sources: env("TEMPLATE_NAME"),
Category: shared.CategoryTemplates,
OnlyOnce: true,
Config: trim,
}
defaultCodeToRenderFlag = cli.UintFlag{
Name: "default-error-page",
Usage: "The code of the default (index page, when a code is not specified) error page to render",
Value: uint64(cfg.DefaultCodeToRender),
Sources: env("DEFAULT_ERROR_PAGE"),
Category: shared.CategoryCodes,
Name: "default-error-page",
Usage: "the code of the default (index page, when a code is not specified) error page to render",
Value: uint64(cfg.DefaultCodeToRender),
Sources: env("DEFAULT_ERROR_PAGE"),
Validator: func(code uint64) error {
if code > 999 { //nolint:mnd
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
@ -99,19 +94,17 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
}
sendSameHTTPCodeFlag = cli.BoolFlag{
Name: "send-same-http-code",
Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
Usage: "the HTTP response should have the same status code as the requested error page (by default, " +
"every response with an error page will have a status code of 200)",
Value: cfg.RespondWithSameHTTPCode,
Sources: env("SEND_SAME_HTTP_CODE"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
showDetailsFlag = cli.BoolFlag{
Name: "show-details",
Usage: "Show request details in the error page response (if supported by the template)",
Usage: "show request details in the error page response (if supported by the template)",
Value: cfg.ShowDetails,
Sources: env("SHOW_DETAILS"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
proxyHeadersListFlag = cli.StringFlag{
@ -129,16 +122,14 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
return nil
},
Category: shared.CategoryOther,
OnlyOnce: true,
Config: trim,
}
rotationModeFlag = cli.StringFlag{
Name: "rotation-mode",
Value: config.RotationModeDisabled.String(),
Usage: "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
Usage: "templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
Sources: env("TEMPLATES_ROTATION_MODE"),
Category: shared.CategoryTemplates,
OnlyOnce: true,
Config: trim,
Validator: func(s string) error {
@ -149,34 +140,23 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
return nil
},
}
readBufferSizeFlag = cli.UintFlag{
Name: "read-buffer-size",
Usage: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
"large cookies), note that increasing this value will increase memory consumption)",
Value: 1024 * 5, //nolint:mnd // 5 KB
Sources: env("READ_BUFFER_SIZE"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
)
// override some flag usage messages
addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
addrFlag.Usage = "the HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1 for localhost, " +
"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"
portFlag.Usage = "the TCP port number for the HTTP server to listen on (0-65535)"
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
cmd.c = &cli.Command{
Name: "serve",
Aliases: []string{"s", "server", "http"},
Usage: "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
Usage: "Start HTTP server",
Suggest: true,
Action: func(ctx context.Context, c *cli.Command) error {
cmd.opt.http.addr = c.String(addrFlag.Name)
cmd.opt.http.port = uint16(c.Uint(portFlag.Name))
cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name))
cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
@ -302,7 +282,6 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
&showDetailsFlag,
&proxyHeadersListFlag,
&rotationModeFlag,
&readBufferSizeFlag,
},
}
@ -311,7 +290,7 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
// Run current command.
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen
var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)
var srv = appHttp.NewServer(log)
if err := srv.Register(cfg); err != nil {
return err

View File

@ -36,7 +36,7 @@ func TestCommand_Run(t *testing.T) {
"--add-template", "./testdata/foo-template.html",
"--disable-template", "ghost",
"--disable-template", "<unknown>",
"--add-code", "200=Code/Description",
"--add-http-code", "200=Code/Description",
"--json-format", "json format",
"--xml-format", "xml format",
"--plaintext-format", "plaintext format",

View File

@ -11,15 +11,6 @@ import (
"gh.tarampamp.am/error-pages/internal/config"
)
const (
CategoryHTTP = "HTTP:"
CategoryTemplates = "TEMPLATES:"
CategoryCodes = "HTTP CODES:"
CategoryFormats = "FORMATS:"
CategoryBuild = "BUILD:"
CategoryOther = "OTHER:"
)
// Note: Don't use pointers for flags, because they have own state which is not thread-safe.
// https://github.com/urfave/cli/issues/1926
@ -29,7 +20,6 @@ var ListenAddrFlag = cli.StringFlag{
Usage: "IP (v4 or v6) address to listen on",
Value: "0.0.0.0", // bind to all interfaces by default
Sources: cli.EnvVars("LISTEN_ADDR"),
Category: CategoryHTTP,
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(ip string) error {
@ -51,7 +41,6 @@ var ListenPortFlag = cli.UintFlag{
Usage: "TCP port number",
Value: 8080, // default port number
Sources: cli.EnvVars("LISTEN_PORT"),
Category: CategoryHTTP,
OnlyOnce: true,
Validator: func(port uint64) error {
if port == 0 || port > 65535 {
@ -64,10 +53,9 @@ var ListenPortFlag = cli.UintFlag{
var AddTemplatesFlag = cli.StringSliceFlag{
Name: "add-template",
Usage: "To add a new template, provide the path to the file using this flag (the filename without the extension " +
Usage: "to add a new template, provide the path to the file using this flag (the filename without the extension " +
"will be used as the template name)",
Config: cli.StringConfig{TrimSpace: true},
Category: CategoryTemplates,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(paths []string) error {
for _, path := range paths {
if path == "" {
@ -84,19 +72,18 @@ var AddTemplatesFlag = cli.StringSliceFlag{
}
var DisableTemplateNamesFlag = cli.StringSliceFlag{
Name: "disable-template",
Usage: "Disable the specified template by its name (useful to disable the built-in templates and use only custom ones)",
Config: cli.StringConfig{TrimSpace: true},
Category: CategoryTemplates,
Name: "disable-template",
Usage: "disable the specified template by its name (useful to disable the built-in templates and use only custom ones)",
Config: cli.StringConfig{TrimSpace: true},
}
var AddHTTPCodesFlag = cli.StringMapFlag{
Name: "add-code",
Usage: "To add a new HTTP status code, provide the code and its message/description using this flag (the format " +
Name: "add-http-code",
Aliases: []string{"add-code"},
Usage: "to add a new HTTP status code, provide the code and its message/description using this flag (the format " +
"should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at " +
"once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously)",
Config: cli.StringConfig{TrimSpace: true},
Category: CategoryCodes,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(codes map[string]string) error {
for code, msgAndDesc := range codes {
if code == "" {
@ -141,8 +128,7 @@ func ParseHTTPCodes(codes map[string]string) map[string]config.CodeDescription {
var DisableL10nFlag = cli.BoolFlag{
Name: "disable-l10n",
Usage: "Disable localization of error pages (if the template supports localization)",
Usage: "disable localization of error pages (if the template supports localization)",
Sources: cli.EnvVars("DISABLE_L10N"),
Category: CategoryOther,
OnlyOnce: true,
}

View File

@ -122,7 +122,7 @@ func TestAddHTTPCodesFlag(t *testing.T) {
var flag = shared.AddHTTPCodesFlag
assert.Equal(t, "add-code", flag.Name)
assert.Equal(t, "add-http-code", flag.Name)
for name, tt := range map[string]struct {
giveValue map[string]string

View File

@ -72,7 +72,7 @@ const defaultJSONFormat string = `{
"service_name": {{ service_name | json }},
"service_port": {{ service_port | json }},
"request_id": {{ request_id | json }},
"timestamp": {{ nowUnix }}
"timestamp": {{ now.Unix }}
}{{ end }}
}
` // an empty line at the end is important for better UX
@ -91,7 +91,7 @@ const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
<serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID>
<timestamp>{{ nowUnix }}</timestamp>
<timestamp>{{ now.Unix }}</timestamp>
</details>{{ end }}
</error>
` // an empty line at the end is important for better UX
@ -107,7 +107,7 @@ Ingress Name: {{ ingress_name }}
Service Name: {{ service_name }}
Service Port: {{ service_port }}
Request ID: {{ request_id }}
Timestamp: {{ nowUnix }}{{ end }}
Timestamp: {{ now.Unix }}{{ end }}
` // an empty line at the end is important for better UX
//nolint:lll

View File

@ -1,111 +0,0 @@
package error_page
import (
"bytes"
"crypto/md5" //nolint:gosec
"encoding/gob"
"sync"
"time"
"gh.tarampamp.am/error-pages/internal/template"
)
type (
// RenderedCache is a cache for rendered error pages. It's safe for concurrent use.
// It uses a hash of the template and props as a key.
//
// To remove expired items, call ClearExpired method periodically (a bit more often than the ttl).
RenderedCache struct {
ttl time.Duration
mu sync.RWMutex
items map[[32]byte]cacheItem // map[template_hash[0:15];props_hash[16:32]]cache_item
}
cacheItem struct {
content []byte
addedAtNano int64
}
)
// NewRenderedCache creates a new RenderedCache with the specified ttl.
func NewRenderedCache(ttl time.Duration) *RenderedCache {
return &RenderedCache{ttl: ttl, items: make(map[[32]byte]cacheItem)}
}
// genKey generates a key for the cache item by hashing the template and props.
func (rc *RenderedCache) genKey(template string, props template.Props) [32]byte {
var (
key [32]byte
th, ph = hash(template), hash(props) // template hash, props hash
)
copy(key[:16], th[:]) // first 16 bytes for the template hash
copy(key[16:], ph[:]) // last 16 bytes for the props hash
return key
}
// Has checks if the cache has an item with the specified template and props.
func (rc *RenderedCache) Has(template string, props template.Props) bool {
var key = rc.genKey(template, props)
rc.mu.RLock()
_, ok := rc.items[key]
rc.mu.RUnlock()
return ok
}
// Put adds a new item to the cache with the specified template, props, and content.
func (rc *RenderedCache) Put(template string, props template.Props, content []byte) {
var key = rc.genKey(template, props)
rc.mu.Lock()
rc.items[key] = cacheItem{content: content, addedAtNano: time.Now().UnixNano()}
rc.mu.Unlock()
}
// Get returns the content of the item with the specified template and props.
func (rc *RenderedCache) Get(template string, props template.Props) ([]byte, bool) {
var key = rc.genKey(template, props)
rc.mu.RLock()
item, ok := rc.items[key]
rc.mu.RUnlock()
return item.content, ok
}
// ClearExpired removes all expired items from the cache.
func (rc *RenderedCache) ClearExpired() {
rc.mu.Lock()
var now = time.Now().UnixNano()
for key, item := range rc.items {
if now-item.addedAtNano > rc.ttl.Nanoseconds() {
delete(rc.items, key)
}
}
rc.mu.Unlock()
}
// Clear removes all items from the cache.
func (rc *RenderedCache) Clear() {
rc.mu.Lock()
clear(rc.items)
rc.mu.Unlock()
}
// hash returns an MD5 hash of the provided value (it may be any built-in type).
func hash(in any) [16]byte {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(in); err != nil {
return [16]byte{} // never happens because we encode only built-in types
}
return md5.Sum(b.Bytes()) //nolint:gosec
}

View File

@ -1,86 +0,0 @@
package error_page_test
import (
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"gh.tarampamp.am/error-pages/internal/template"
)
func TestRenderedCache_CRUD(t *testing.T) {
t.Parallel()
var cache = error_page.NewRenderedCache(time.Millisecond)
t.Run("has", func(t *testing.T) {
assert.False(t, cache.Has("template", template.Props{}))
cache.Put("template", template.Props{}, []byte("content"))
assert.True(t, cache.Has("template", template.Props{}))
assert.False(t, cache.Has("template", template.Props{Code: 1}))
assert.False(t, cache.Has("foo", template.Props{Code: 1}))
})
t.Run("exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{})
assert.True(t, ok)
assert.Equal(t, []byte("content"), got)
cache.Clear()
assert.False(t, cache.Has("template", template.Props{}))
})
t.Run("not exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{Code: 2})
assert.False(t, ok)
assert.Nil(t, got)
})
t.Run("race condition provocation", func(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
cache.Get("template", template.Props{})
cache.Put("template"+strconv.Itoa(i), template.Props{}, []byte("content"))
cache.Has("template", template.Props{})
}(i)
go func() {
defer wg.Done()
cache.ClearExpired()
}()
}
wg.Wait()
})
}
func TestRenderedCache_Expiring(t *testing.T) {
t.Parallel()
var cache = error_page.NewRenderedCache(10 * time.Millisecond)
cache.Put("template", template.Props{}, []byte("content"))
cache.ClearExpired()
assert.True(t, cache.Has("template", template.Props{}))
<-time.After(10 * time.Millisecond)
assert.True(t, cache.Has("template", template.Props{})) // expired, but not cleared yet
cache.ClearExpired()
assert.False(t, cache.Has("template", template.Props{})) // cleared
}

View File

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
@ -16,32 +15,7 @@ import (
)
// New creates a new handler that returns an error page with the specified status code and format.
func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, closeCache func()) { //nolint:funlen,gocognit,gocyclo,lll
// if the ttl will be bigger than 1 second, the template functions like `nowUnix` will not work as expected
const cacheTtl = 900 * time.Millisecond // the cache TTL
var (
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
stopOnce sync.Once
)
// run a goroutine that will clear the cache from expired items. to stop the goroutine - close the stop channel
// or call the closeCache
go func() {
var timer = time.NewTimer(cacheTtl)
defer func() { timer.Stop(); cache.Clear() }()
for {
select {
case <-timer.C:
cache.ClearExpired()
timer.Reset(cacheTtl)
case <-stopCh:
return
}
}
}()
func New(cfg *config.Config, log *logger.Logger) fasthttp.RequestHandler { //nolint:funlen,gocognit,gocyclo
return func(ctx *fasthttp.RequestCtx) {
var (
reqHeaders = &ctx.Request.Header
@ -132,82 +106,57 @@ func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, clo
switch {
case format == jsonFormat && cfg.Formats.JSON != "":
if cached, ok := cache.Get(cfg.Formats.JSON, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, errAsJson) // error during rendering
} else {
cache.Put(cfg.Formats.JSON, tplProps, []byte(content))
write(ctx, log, content) // rendered successfully
}
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
j, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, j)
} else {
write(ctx, log, content)
}
case format == xmlFormat && cfg.Formats.XML != "":
if cached, ok := cache.Get(cfg.Formats.XML, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
))
} else {
cache.Put(cfg.Formats.XML, tplProps, []byte(content))
write(ctx, log, content)
}
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>", err.Error(),
))
} else {
write(ctx, log, content)
}
case format == htmlFormat:
var templateName = templateToUse(cfg)
if tpl, found := cfg.Templates.Get(templateName); found { //nolint:nestif
if cached, ok := cache.Get(tpl, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
templateName,
err.Error(),
))
} else {
cache.Put(tpl, tplProps, []byte(content))
write(ctx, log, content)
}
if tpl, found := cfg.Templates.Get(templateName); found {
if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>",
templateName,
err.Error(),
))
} else {
write(ctx, log, content)
}
} else {
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>\n", templateName,
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>", templateName,
))
}
default: // plainTextFormat as default
if cfg.Formats.PlainText != "" { //nolint:nestif
if cached, ok := cache.Get(cfg.Formats.PlainText, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else {
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))
write(ctx, log, content)
}
if cfg.Formats.PlainText != "" {
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else {
write(ctx, log, content)
}
} else {
write(ctx, log, `The requested content format is not supported.
Please create an issue on the project's GitHub page to request support for this format.
Supported formats: JSON, XML, HTML, Plain Text
`)
Supported formats: JSON, XML, HTML, Plain Text`)
}
}
}, func() { stopOnce.Do(func() { close(stopCh) }) }
}
}
var (

View File

@ -159,8 +159,7 @@ func TestHandler(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
var handler, closeCache = error_page.New(tt.giveConfig(), logger.NewNop())
defer closeCache()
var handler = error_page.New(tt.giveConfig(), logger.NewNop())
req, reqErr := http.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody)
require.NoError(t, reqErr)
@ -203,11 +202,9 @@ func TestRotationModeOnEachRequest(t *testing.T) {
lastResponseBody string
changedTimes int
handler, closeCache = error_page.New(&cfg, logger.NewNop())
handler = error_page.New(&cfg, logger.NewNop())
)
defer func() { closeCache(); closeCache(); closeCache() }() // multiple calls should not panic
for range 300 {
req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody)
require.NoError(t, reqErr)

View File

@ -23,16 +23,16 @@ import (
// Server is an HTTP server for serving error pages.
type Server struct {
log *logger.Logger
server *fasthttp.Server
beforeStop func()
log *logger.Logger
server *fasthttp.Server
}
// NewServer creates a new HTTP server.
func NewServer(log *logger.Logger, readBufferSize uint) Server {
func NewServer(log *logger.Logger) Server {
const (
readTimeout = 30 * time.Second
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
readTimeout = 30 * time.Second
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
)
return Server{
@ -40,38 +40,33 @@ func NewServer(log *logger.Logger, readBufferSize uint) Server {
server: &fasthttp.Server{
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ReadBufferSize: int(readBufferSize),
ReadBufferSize: maxHeaderBytes,
DisablePreParseMultipartForm: true,
NoDefaultServerHeader: true,
CloseOnShutdown: true,
Logger: logger.NewStdLog(log),
},
beforeStop: func() {}, // noop
}
}
// Register server handlers, middlewares, etc.
func (s *Server) Register(cfg *config.Config) error { //nolint:funlen
func (s *Server) Register(cfg *config.Config) error {
var (
liveHandler = live.New()
versionHandler = version.New(appmeta.Version())
faviconHandler = static.New(static.Favicon)
errorPagesHandler, closeCache = ep.New(cfg, s.log)
liveHandler = live.New()
versionHandler = version.New(appmeta.Version())
errorPagesHandler = ep.New(cfg, s.log)
faviconHandler = static.New(static.Favicon)
notFound = http.StatusText(http.StatusNotFound) + "\n"
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
)
// wrap the before shutdown function to close the cache
s.beforeStop = closeCache
s.server.Handler = func(ctx *fasthttp.RequestCtx) {
var url, method = string(ctx.Path()), string(ctx.Method())
switch {
// live endpoints
case url == "/healthz" || url == "/health/live" || url == "/health" || url == "/live":
case url == "/health/live" || url == "/health" || url == "/healthz" || url == "/live":
liveHandler(ctx)
// version endpoint
@ -87,9 +82,8 @@ func (s *Server) Register(cfg *config.Config) error { //nolint:funlen
// - /{code}.html
// - /{code}.htm
// - /{code}
//
// the HTTP method is not limited to GET and HEAD - it can be any
case url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(&ctx.Request.Header):
case method == fasthttp.MethodGet &&
(url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(&ctx.Request.Header)):
errorPagesHandler(ctx)
// wrong requests handling
@ -141,7 +135,5 @@ func (s *Server) Stop(timeout time.Duration) error {
var ctx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
s.beforeStop()
return s.server.ShutdownWithContext(ctx)
}

View File

@ -20,7 +20,7 @@ import (
// TestRouting in fact is a test for the whole server, because it tests all the routes and their handlers.
func TestRouting(t *testing.T) {
var (
srv = appHttp.NewServer(logger.NewNop(), 1025*5)
srv = appHttp.NewServer(logger.NewNop())
cfg = config.New()
)
@ -38,7 +38,7 @@ func TestRouting(t *testing.T) {
Service Name: {{ service_name }}
Service Port: {{ service_port }}
Request ID: {{ request_id }}
Timestamp: {{ nowUnix }}
Timestamp: {{ now.Unix }}
</pre>{{ end }}
</html>`))
@ -221,16 +221,6 @@ func TestRouting(t *testing.T) {
assert.Contains(t, string(body), "404: Not Found")
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
})
t.Run("other HTTP methods", func(t *testing.T) {
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
var status, body, headers = sendRequest(t, method, baseUrl+"/404.html")
assert.Equal(t, http.StatusOK, status)
assert.Contains(t, string(body), "404: Not Found")
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
}
})
})
t.Run("failure", func(t *testing.T) {
@ -274,6 +264,15 @@ func TestRouting(t *testing.T) {
assert.Equal(t, http.StatusNotFound, status)
assertIsNotErrorPage(t, body)
})
t.Run("invalid HTTP methods", func(t *testing.T) {
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
var status, body, _ = sendRequest(t, method, baseUrl+"/404.html")
assert.Equal(t, http.StatusMethodNotAllowed, status)
assertIsNotErrorPage(t, body)
}
})
})
})

View File

@ -16,9 +16,10 @@ import (
)
var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals
// the current time in unix format (seconds since 1970 UTC):
// `{{ nowUnix }}` // `1631610000`
"nowUnix": func() int64 { return time.Now().Unix() },
// current time:
// `{{ now.Unix }}` // `1631610000`
// `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}` // `15:4:5`
"now": time.Now,
// current hostname:
// `{{ hostname }}` // `localhost`

View File

@ -27,7 +27,7 @@ func TestRender_BuiltInFunction(t *testing.T) {
wantErrMsg string
}{
"now (unix)": {
giveTemplate: `{{ nowUnix }}`,
giveTemplate: `{{ now.Unix }}`,
wantResult: strconv.Itoa(int(time.Now().Unix())),
},
"hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname},

View File

@ -341,7 +341,7 @@
<!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
</ul>
</div>
<!-- {{- end -}} -->

View File

@ -150,7 +150,7 @@
<!-- {{- end -}} -->
<tr>
<td class="name" data-l10n>Timestamp</td>
<td class="value">{{ nowUnix }}</td>
<td class="value">{{ now.Unix }}</td>
</tr>
</tbody>
</table>

View File

@ -327,7 +327,7 @@
<!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
</ul>
</div>
<!-- {{- end -}} -->

View File

@ -235,7 +235,7 @@
<!-- {{- end -}} -->
<tr>
<td class="name" data-l10n>Timestamp</td>
<td class="value">{{ nowUnix }}</td>
<td class="value">{{ now.Unix }}</td>
</tr>
</tbody>
</table>

View File

@ -174,7 +174,7 @@
<!-- {{- end }}{{ if request_id -}} -->
<p class="output small"><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></p>
<!-- {{- end -}} -->
<p class="output small"><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></p>
<p class="output small"><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></p>
</div>
<!-- {{- end -}} -->
</main>

View File

@ -157,7 +157,7 @@
<!-- {{- end }}{{ if request_id -}} -->
<li class="value">{{ request_id }}</li>
<!-- {{- end -}} -->
<li class="value">{{ nowUnix }}</li>
<li class="value">{{ now.Unix }}</li>
</ul>
<!-- {{- end -}} -->
</div>

View File

@ -442,7 +442,7 @@
<!-- {{- end }}{{ if request_id -}} -->
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
<!-- {{- end -}} -->
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
</ul>
<!-- {{- end -}} -->
</div>

View File

@ -9,7 +9,7 @@
{{ if service_name }}Service name: {{ service_name }}{{ end }}
{{ if service_port }}Service port: {{ service_port }}{{ end }}
{{ if request_id }}Request ID: {{ request_id }}{{ end }}
Timestamp: {{ nowUnix }}
Timestamp: {{ now.Unix }}
{{ end }}
-->
<html lang="en">

View File

@ -253,7 +253,7 @@
<!-- {{- end -}} -->
<tr>
<td class="name" data-l10n>Timestamp</td>
<td class="value">{{ nowUnix }}</td>
<td class="value">{{ now.Unix }}</td>
</tr>
</table>
</div>

View File

@ -167,7 +167,7 @@
<!-- {{- end -}} -->
<tr>
<td class="name"><span data-l10n>Timestamp</span>:</td>
<td class="value">{{ nowUnix }}</td>
<td class="value">{{ now.Unix }}</td>
</tr>
</table>
<!-- {{- end -}} -->