I'm developing a webgame. As part of the game, you start out with a limited set of features, and you unlock more of them as you play.

For instance, you unlock /fields as part of step 3 in the tutorial. But what if you just navigate to /fields in the address bar?

I'm trying to work out what would be the best status code to respond with.

403 seems ideal since the user is forbidden from accessing the page until they unlock it.
404 also makes sense since the page technically "doesn't exist" until it is unlocked and also prevents users from being able to tell the difference between a page that doesn't exist and one that they just haven't unlocked yet.

But in both cases I've had some users report issues with the browser cacheing the 403/404 result and not letting them access the page even after unlocking it unless they purge the cache entirely.

I'm wondering if I should keep using 403 or 404, or should I use an unused 4XX code such as 442 with a custom statusText, or even jokingly send HTTP/1.1 418 I'm A Teapot in response to a user poking around where they shouldn't be.

I need a good, solid reason why one option should be used over the others.

3 Answers
3

tl;dr409 Conflict would be an idea, but perhaps you have problems with caching. In this case a cache-buster to force a reload will work.

Long explanation

Perhaps a 409 Conflict status code would make sense:

10.4.10 409 Conflict

The request could not be completed due to a conflict with the current state of the resource. This code is only allowed in situations where it is expected that the user might be able to resolve the conflict and resubmit the request. The response body SHOULD include enough information for the user to recognize the source of the conflict. Ideally, the response entity would include enough information for the user or user agent to fix the problem; however, that might not be possible and is not required.

Conflicts are most likely to occur in response to a PUT request. For example, if versioning were being used and the entity being PUT included changes to a resource which conflict with those made by an earlier (third-party) request, the server might use the 409 response to indicate that it can't complete the request. In this case, the response entity would likely contain a list of the differences between the two versions in a format defined by the response Content-Type.

It would make sense, because the resource is only available after the user did the tutorial. Before that the resource is in an «invalid» state. And the user is able to resolve this conflict by completing the tutorial.

Later I investigated the case a little more and I discovered that the devil is in the detail. Let's read the specification for 403 Forbidden and 404 Not Found.

10.4.4 403 Forbidden

The server understood the request, but is refusing to fulfill it. Authorization will not help and the request SHOULD NOT be repeated. If the request method was not HEAD and the server wishes to make public why the request has not been fulfilled, it SHOULD describe the reason for the refusal in the entity. This status code is commonly used when the server does not wish to reveal exactly why the request has been refused, or when no other response is applicable.

Important is the specification that «the request SHOULD NOT be repeated». A browser which never re-requests a 403 page might do the right thing. However, let's continue with 404:

10.4.5 404 Not Found

The server has not found anything matching the Request-URI. No indication is given of whether the condition is temporary or permanent.

[omitted]

Now we have a problem! Why would your 404 pages be cached if the specification allows them to be temporary?

Perhaps in your setup you have caching configured not correctly for your 403 and 404 pages. If this is so, please consult this answer on StackOverflow. It gives a detailed answer about caching 4xx pages.

If you don't want to mess with caching headers, use a so-called cache-buster and pass the system time like this (assuming PHP as your web language):

<a href="/fields?<?php echo time(); ?>">

This produces URLs like /fields?1361948122, increasing every second. It's a variant of the solution proposed by Markus A.

I assume the querystring 1361948122 is ignored by your resource. If it is not, pass the cache-buster in a querystring parameter instead, for example t=1361948122 and make sure that the parameter t is not evaluated by your resource.

In terms of the intended purpose of the HTTP error codes, I would definitely go with 403 Forbidden, because the page does exist (404 is out), but the user is forbidden to access it for now (and this restriction is not due to a resource conflict, like concurrent modification, but due to the user's account status, i.e. 409 is out as well in my opinion). Another sensible option based on it's intended purpose could have been 401, but as nalply already noted in his comment, this code triggers some, if not all, browsers to display a login dialog, as it implies that using the standard web-authentication mechanism can resolve the issue. So, it would definitely not be an option for you here.

Two things seem a little "misfitting" in the description of 403, so let me address them:

Authorization will not help ...: This only talks about the authorization mechanism inside the HTTP protocol and is meant to distinguish 403 from 401. This statement does not apply to any form of custom authorization or session state management.

... the request SHOULD NOT be repeated ...: A request must always be seen in the session context, so if the session context of the user changes (he unlocks a feature) and then he retries accessing the same resource, that is a different request, i.e. there is no violation of this suggestion.

Of course, you could also define your own error code, but since it probably won't be reserved in any official way, there is no guarantee that some browser manufacturer isn't going to intentionally or accidentally use exactly that code to trigger a specific (debugging) action. It's unlikely, but not disallowed.

418 could be OK, too, though. :)

Of course, if you would like to specifically obscure the potential availability of features, you could also decide to use 404 as that is the only way to not give a nosy user any hints.

Now, to your caching issue:

Neither one of these status codes (403, 404, 409, 418) should trigger the browser to cache the page against your will more than any other. The problem is that many browser simply try to cache everything like crazy to be extra snappy. Opera is the worst here in my opinion. I've been pulling my hair out many times over these things. It SHOULD be possible to work it all out with the correct header settings, but I've had situations where either the browser or the server or some intermediate proxy decided to ignore them and break my page anyways.

The only sure-fire way that I have found so far that absolutely positively guarantees a reload is to add a dummy request parameter like /fields?t=29873, where 29873 is a number that is unique for every request you make within any possibly relevant time scales. On the server, of course, you can then simply ignore this parameter. Note that it is not enough to simply start at 1 when your user first opens your page and then count up for following requests, as browsers might keep the cache around across page-reloads.

I do my web-development in Java (both server and client-side using GWT) and I use this code to generate the dummy "numbers":

It uses the system's clock in combination with a counter to be able to provide up to about two guaranteed unique values every ms. You might not need this speed, so you can feel free to change the >>> 5 that I marked with "adjust" to fit your needs. If you increase it by 1, your rate goes down by a factor of two and your uniqueness time-span doubles. So, for example, if you put >>> 8 instead, you can generate about 1 value every 4 ms and the values should not repeat for 3200 days. Of course, this guarantee that the values will not repeat will go away if the user messes with the system clock. But since these values are not generated sequentially, it is still very unlikely that you will hit the same number twice. The code generates a 6-character text-string (base64) rather than a decimal number to keep the URLs as short as possible.