One of the Platform team’s goals with the V2 API is to conform to the HTTP spec and support as many of its standard features as we can. The following is an account of how we arrived at our current level of support for the If-Match header.
If-Match and ETags
The If-Match header provides a way for clients to make safe updates. We use it on PUTs, DELETEs, and file overwrites via POST. The client asserts with the If-Match header, “I am updating version XYZ.” If XYZ is not the current version of the object, we must reject the update with a 412 Precondition Failed. The client is free to get updated information about the object, resolve conflicts, or do whatever it needs to do to attempt to make the update again.
The XYZ part of the If-Match header is known as an ETag (or entity tag). ETags are identifiers that are bound to a particular version of an object. A given ETag must be unique across all versions of an object.
Initial implementation: required on file overwrites and deletions
Our first implementation of If-Match in the API V2 added it as a required header for all file overwrites and file deletions. We were using the file’s sha1 as the ETag. The decision to do so stems from the fact that in the response to GET /files/id, the sha1 attribute was named ETag. We had no additional field for the sha1.
Once the code that required this header went live, we saw a spike in 428 Precondition Required codes being returned by the API. (Yay breaking changes, but that’s a post for another day.)
Our initial implementation was live for about a month before we realized some problems with it:
- sha1 is not a sufficent ETag. Files can change on Box in other ways than altering their content. For example, changing a file’s name, owner, or trashed status.
- We would like to support Test-and-Set semantics on our PUT endpoints, yet our current implementation of If-Match did not use transactions.
- Requiring If-Match creates an unnecessary barrier to entry to using our API
Standards compliant implementation: All file and folder operations that can modify the object
Migrating away from sha1 was fairly straightforward: for the endpoints that already required sha1 If-Match headers, we made sha1 one of the possible params to validate against. For these and all endpoints, we added sequence_id as another possibility and made using either of the fields optional for API callers. Sequence ids exist on several of our models and are essentially monotonically incrementing INTs that are bumped when a core attribute of the model object changes. This is perfect as an ETag because they are guaranteed to be unique and they point to a specific version of an object in Box.
To be more compliant with the spec, we added support for If-Match: * by adding a validation call that checks if the object in question exists and aborts with a 412 Precondition Failed otherwise.
The biggest challenge in implementing standards compliant If-Match support is the part of the HTTP spec that says:
If the request would, without the If-Match header field, result in anything other than a 2xx or 412 status, then the If-Match header MUST be ignored.
This means, if any error might occur during this request, it must bubble up to the caller. So we added support to the actions layer (our internal API) for specifying the ETag to validate against and wrapped grabbing the ETag, calling the action, and validating the ETag in a DB transaction. As part of this DB transaction, we grabbed an X-lock on the item to prevent concurrent updates. After all, our primary reason for supporting If-Match was to have a safe update mechanism.
This approach was nice because it added support for conditional validation at a layer below all API calls, making it reusable in all endpoints that may wish to support If-Match; it gave us test-and-set for free; and it made use of the exception handling framework we already had in place.
All of this would have been perfect if Actions, our internal API, had no side effects … but they do: Logs go off to Splunk and Hive, all for an operation that might be aborted after the fact because an ETag didn’t match.
95% correct solution
Fortunately there was an easy tradeoff to make here: A large chunk of exceptions we experience in API from the Actions framework occur in the additional_validate phase, which has no side effects.
The solution we ended up with that makes slight compromises on correctness and adherence to the HTTP spec in return for preventing spurious side effects is:
DB TRANSACTION START IF-MATCH: * CHECK FETCH ETAG ATTRIBUTE FROM OBJECT ADDITIONAL_VALIDATE <-- exceptions thrown here VALIDATE ETAG EXECUTE ACTION DB TRANSACTION END
The If-Match header in this form is now live in API V2.