Simulating timeout in GET function

main
Drew Bednar 4 hours ago
parent 25189e1c5b
commit b1d68e2877

@ -39,4 +39,27 @@ We can try to use the xargs tool to submit multiple requests to our webserver. H
```bash
xargs -I % -P8 curl -X PATCH -d '{"runtime": "97 mins"}' "localhost:5002/v1/movies/4" < <(printf '%s\n' {1..8})
```
## Adding Time Taken To You Curl Request
Use the `-w` to anotate the response `https://blog.josephscott.org/2011/10/14/timing-details-with-curl/`
```bash
curl -w '\nTime: %{time_total}s \n' localhost:5002/v1/movies/4
{
"movie": {
"id": 4,
"title": "The Batman",
"year": 2021,
"Runtime": "97 mins",
"genres": [
"action",
"adventure"
],
"version": 5
}
}
Time: 8.009385s
```

@ -0,0 +1,195 @@
# Request Context
Source:
Let's Go Further
Previous · Contents · Next
Chapter 21.6.
Request Context Timeouts
As an alternative to the pattern that we implemented in chapter 8.3 for managing database timeouts, we could have created a context with a timeout in our handlers and then passed the context on to our database model.
Very roughly, a pattern like this:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
...
// Create a context.Context with a one-second timeout deadline and which has
// context.Background() as the 'parent'.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Pass the context on to the Get() method.
example, err := app.models.Example.Get(ctx, id)
if err != nil {
switch {
case errors.Is(err, data.ErrNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
...
}
func (m ExampleModel) Get(ctx context.Context, id int64) (*Example, error) {
query := `SELECT ... FROM examples WHERE id = $1`
var example Example
err := m.DB.QueryRowContext(ctx, query, id).Scan(...)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
default:
return nil, err
}
}
return &example, nil
}
The key advantage of this pattern is the extra flexibility; whatever is calling the database model can easily control the timeout duration rather than it always being a fixed duration set by the database model.
Using request context as the parent
As another option, you could use this pattern with the request context as the context parent (instead of context.Background()). Like so:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
...
// Use the request context as the parent.
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
example, err := app.models.Example.Get(ctx, id)
if err != nil {
switch {
case errors.Is(err, data.ErrNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
...
}
In many ways, doing this would be considered good practice — allowing context to flow through your application is generally a good idea.
But using the request context as the parent introduces a lot of additional complexity.
The key thing to be aware of is that the request context will be canceled if the client closes their HTTP connection. From the net/http docs:
For incoming server requests, the [request] context is canceled when the clients connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.
If we use the request context as the parent, then this cancellation signal will bubble down to our database driver pq and the running SQL query will be terminated in exactly the same way that it is when a context timeout is reached. Importantly, it means we would receive the same pq: canceling statement due to user request error message that we do when a database query times out.
On one hand this is a positive thing — if there is no client left to return a response to, we may as well cancel the SQL query and free-up resources.
On the other hand, we will receive the same error message in two very different scenarios:
When a database query takes too long to complete, in which case we would want to log it as an error.
When a client closes the connection — which can happen for many innocuous reasons, such as a user closing a browser tab or terminating a process. Its not really an error from our applications point of view, and we would probably want to either ignore it or perhaps log it as a warning.
Fortunately, it is possible to tell these two scenarios apart by calling the ctx.Err() method on the context. If the context was canceled (due to a client closing the connection), then ctx.Err() will return the error context.Canceled. If the timeout was reached, then it will return context.DeadlineExceeded instead. If both the deadline is reached and the context is canceled, then ctx.Err() will surface whichever happened first.
Its also important to be aware that you may receive a context.Canceled error if the client closes the connection while the query is queued by sql.DB, and likewise Scan() may return a context.Canceled error too.
Putting all that together, a sensible way to manage this is to check for the error pq: canceling statement due to user request in our database model and wrap it together with the error from ctx.Err() before returning. For example:
func (m ExampleModel) Get(ctx context.Context, id int64) (*Example, error) {
query := `SELECT ... FROM examples WHERE id = $1`
var example Example
err := m.DB.QueryRowContext(ctx, query, id).Scan(...)
if err != nil {
switch {
case err.Error() == "pq: canceling statement due to user request":
// Wrap the error with ctx.Err().
return nil, fmt.Errorf("%w: %w", err, ctx.Err())
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
default:
return nil, err
}
}
return &example, nil
}
Then in your handlers you can use errors.Is() to check if the error returned by the database model is equal to (or wraps) context.Canceled, and manage it accordingly.
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
...
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
example, err := app.models.Example.Get(ctx, id)
if err != nil {
switch {
// If the error is equal to or wraps context.Canceled, then it's not
// really an error and we return without taking any further action.
case errors.Is(err, context.Canceled):
return
case errors.Is(err, data.ErrNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
...
}
As well as this additional error handling, you also need to be aware of the implications when using background goroutines. Bear in mind what I quoted earlier about request context cancellation:
For incoming server requests, the [request] context is canceled … when the ServeHTTP method returns.
This means that if you derive your context from the request context, any SQL queries using the context in a long-running background goroutine will be canceled when the HTTP response is sent for the request! If you dont want that to be the case (and you probably dont), then you would need to create a brand-new context for the background goroutine using context.Background() anyway.
So, to summarize, using the request context as the parent context for database timeouts adds quite a lot of behavioral complexity and introduces nuances that you and anyone else working on the codebase needs to be aware of. You have to ask: is it worth it?
For most applications, on most endpoints, its probably not. The exceptions are probably applications which frequently run close to saturation point of their resources, or for specific endpoints which execute slow running or very computationally expensive SQL queries. In those cases, canceling queries aggressively when a client disappears may have a meaningful positive impact and make it worth the trade-off.
The context.WithoutCancel function
Since Go 1.21, there is another option. You can use the request context as the parent context but wrap it with the context.WithoutCancel() function, like so:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
...
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Second)
defer cancel()
example, err := app.models.Example.Get(ctx, id)
if err != nil {
switch {
case errors.Is(err, data.ErrNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
...
}
The context.WithoutCancel() function returns a copy of parent that is not canceled when parent is canceled. But otherwise, it is identical to the parent — and importantly it retains the same context values as the parent.
So, when we use this like the example above, the context containing the database timeout will not be cancelled if the client closes the HTTP connection or when the ServeHTTP method returns. That means we avoid all the complexity and extra error handling described above. A pq: canceling statement due to user request error will only be returned when there is a timeout.
And the advantage of using this over context.Background() as the parent is that the context will retain any values that the request context has.
So, if you want to control the timeout duration from your handlers (instead of having a fixed timeout in the database model), this pattern provides a nice balance of minimizing complexity while still allowing context values to flow through your application.
Previous
Contents
Next

@ -1,9 +1,11 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"git.runcible.io/learning/pulley/internal/data"
"git.runcible.io/learning/pulley/internal/validator"
@ -83,7 +85,22 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request)
return
}
movie, err := app.models.Movies.Get(r.Context(), id)
// There are many nuances to using the request context as the
// parent context
// 1. HTTP Server disconnect will cancel the db query
// 2. Using pgx over the pg lib we get better error messages
// a. "msg":"context canceled" for HTTP client disconnect
// b. "msg":"timeout: context deadline exceeded"
// 3. A client disconnect isn't necessarily an error but we end
// up logging it at error level
// 4. If that context is used in another goroutine that is meant
// to be longer running than the scope of the http request/response cycle
// Then that goroutine should use context.WithoutCancel which is preserve
// the parent context's values but won't cancel when the parent cancels.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
movie, err := app.models.Movies.Get(ctx, id)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):

@ -52,14 +52,23 @@ func (m MovieModel) Get(ctx context.Context, id int64) (*Movie, error) {
return nil, ErrRecordNotFound
}
// query := `
// SELECT id, created_at, title, year, runtime, genres, version
// FROM movies
// WHERE id = $1
// `
// Mimicking a long running query. FYI since this changes the number of returned
// fields, so I implmented the throwaway variable to take care of that
query := `
SELECT id, created_at, title, year, runtime, genres, version
SELECT pg_sleep(8), id, created_at, title, year, runtime, genres, version
FROM movies
WHERE id = $1
`
var movie Movie
err := m.pool.QueryRow(ctx, query, id).Scan(
&[]byte{}, // throwaway the pg_sleep value
&movie.ID,
&movie.CreatedAt,
&movie.Title,

Loading…
Cancel
Save