1######################################################################
2#
3# File: b2/exception.py
4#
5# Copyright 2018 Backblaze Inc. All Rights Reserved.
6#
7# License https://www.backblaze.com/using_b2_code.html
8#
9######################################################################
10
11from abc import ABCMeta
12
13import json
14import six
15
16from .utils import camelcase_to_underscore
17
18
19@six.add_metaclass(ABCMeta)
20class B2Error(Exception):
21    def __init__(self, *args, **kwargs):
22        """
23        Python 2 does not like it when you pass unicode as the message
24        in an exception.  We like to use file names in exception messages.
25        To avoid problems, if the message has any non-ascii characters in
26        it, they are replaced with backslash-uNNNN
27
28        https://pythonhosted.org/kitchen/unicode-frustrations.html#frustration-5-exceptions
29        """
30        if six.PY2:
31            if args and isinstance(args[0], six.text_type):
32                args = tuple([json.dumps(args[0])[1:-1]] + list(args[1:]))
33        super(B2Error, self).__init__(*args, **kwargs)
34
35    @property
36    def prefix(self):
37        """
38        nice auto-generated error message prefix
39        >>> B2SimpleError().prefix
40        'Simple error'
41        >>> AlreadyFailed().prefix
42        'Already failed'
43        """
44        prefix = self.__class__.__name__
45        if prefix.startswith('B2'):
46            prefix = prefix[2:]
47        prefix = camelcase_to_underscore(prefix).replace('_', ' ')
48        return prefix[0].upper() + prefix[1:]
49
50    def should_retry_http(self):
51        """
52        Returns true if this is an error that can cause an HTTP
53        call to be retried.
54        """
55        return False
56
57    def should_retry_upload(self):
58        """
59        Returns true if this is an error that should tell the upload
60        code to get a new upload URL and try the upload again.
61        """
62        return False
63
64
65@six.add_metaclass(ABCMeta)
66class B2SimpleError(B2Error):
67    """
68    a B2Error with a message prefix
69    """
70
71    def __str__(self):
72        return '%s: %s' % (self.prefix, super(B2SimpleError, self).__str__())
73
74
75@six.add_metaclass(ABCMeta)
76class NotAllowedByAppKeyError(B2SimpleError):
77    """
78    Base class for errors caused by restrictions on an application key.
79    """
80
81
82@six.add_metaclass(ABCMeta)
83class TransientErrorMixin(object):
84    def should_retry_http(self):
85        return True
86
87    def should_retry_upload(self):
88        return True
89
90
91class AlreadyFailed(B2SimpleError):
92    pass
93
94
95class BadDateFormat(B2SimpleError):
96    prefix = 'Date from server'
97
98
99class BadFileInfo(B2SimpleError):
100    pass
101
102
103class BadJson(B2SimpleError):
104    prefix = 'Bad request'
105
106
107class BadUploadUrl(TransientErrorMixin, B2SimpleError):
108    pass
109
110
111class BrokenPipe(B2Error):
112    def __str__(self):
113        return 'Broken pipe: unable to send entire request'
114
115    def should_retry_upload(self):
116        return True
117
118
119class CapabilityNotAllowed(NotAllowedByAppKeyError):
120    pass
121
122
123class ChecksumMismatch(TransientErrorMixin, B2Error):
124    def __init__(self, checksum_type, expected, actual):
125        super(ChecksumMismatch, self).__init__()
126        self.checksum_type = checksum_type
127        self.expected = expected
128        self.actual = actual
129
130    def __str__(self):
131        return '%s checksum mismatch -- bad data' % (self.checksum_type,)
132
133
134class B2HttpCallbackException(B2SimpleError):
135    pass
136
137
138class B2HttpCallbackPostRequestException(B2HttpCallbackException):
139    pass
140
141
142class B2HttpCallbackPreRequestException(B2HttpCallbackException):
143    pass
144
145
146class BucketNotAllowed(NotAllowedByAppKeyError):
147    pass
148
149
150class ClockSkew(B2HttpCallbackPostRequestException):
151    """
152    The clock on the server differs from the local clock by too much.
153    """
154
155    def __init__(self, clock_skew_seconds):
156        """
157        :param clock_skew_seconds: The different: local_clock - server_clock
158        """
159        super(ClockSkew, self).__init__()
160        self.clock_skew_seconds = clock_skew_seconds
161
162    def __str__(self):
163        if self.clock_skew_seconds < 0:
164            return 'ClockSkew: local clock is %d seconds behind server' % (
165                -self.clock_skew_seconds,
166            )
167        else:
168            return 'ClockSkew; local clock is %d seconds ahead of server' % (
169                self.clock_skew_seconds,
170            )
171
172
173class CommandError(B2Error):
174    """
175    b2 command error (user caused). Accepts exactly one argument.
176    We expect users of shell scripts will parse our __str__ output.
177    """
178
179    def __init__(self, message):
180        super(CommandError, self).__init__()
181        self.message = message
182
183    def __str__(self):
184        return self.message
185
186
187class Conflict(B2SimpleError):
188    pass
189
190
191class ConnectionReset(B2Error):
192    def __str__(self):
193        return 'Connection reset'
194
195    def should_retry_upload(self):
196        return True
197
198
199class B2ConnectionError(TransientErrorMixin, B2SimpleError):
200    pass
201
202
203class B2RequestTimeout(TransientErrorMixin, B2SimpleError):
204    pass
205
206
207class DestFileNewer(B2Error):
208    def __init__(self, dest_file, source_file, dest_prefix, source_prefix):
209        super(DestFileNewer, self).__init__()
210        self.dest_file = dest_file
211        self.source_file = source_file
212        self.dest_prefix = dest_prefix
213        self.source_prefix = source_prefix
214
215    def __str__(self):
216        return 'source file is older than destination: %s%s with a time of %s cannot be synced to %s%s with a time of %s, unless --skipNewer or --replaceNewer is provided' % (
217            self.source_prefix,
218            self.source_file.name,
219            self.source_file.latest_version().mod_time,
220            self.dest_prefix,
221            self.dest_file.name,
222            self.dest_file.latest_version().mod_time,
223        )
224
225    def should_retry_http(self):
226        return True
227
228
229class DuplicateBucketName(B2SimpleError):
230    prefix = 'Bucket name is already in use'
231
232
233class FileAlreadyHidden(B2SimpleError):
234    pass
235
236
237class FileNameNotAllowed(NotAllowedByAppKeyError):
238    pass
239
240
241class FileNotPresent(B2SimpleError):
242    pass
243
244
245class UnusableFileName(B2SimpleError):
246    """Raise when a filename doesn't meet the rules.
247
248    Could possibly use InvalidUploadSource, but this is intended for the filename on the
249    server, which could differ.  https://www.backblaze.com/b2/docs/files.html.
250    """
251    pass
252
253
254class InvalidRange(B2Error):
255    def __init__(self, content_length, range_):
256        super(InvalidRange, self).__init__()
257        self.content_length = content_length
258        self.range_ = range_
259
260    def __str__(self):
261        return 'A range of %d-%d was requested (size of %d), but cloud could only serve %d of that' % (
262            self.range_[0],
263            self.range_[1],
264            self.range_[1] - self.range_[0] + 1,
265            self.content_length,
266        )
267
268
269class InvalidUploadSource(B2SimpleError):
270    pass
271
272
273class Unauthorized(B2Error):
274    def __init__(self, message, code):
275        super(Unauthorized, self).__init__()
276        self.message = message
277        self.code = code
278
279    def __str__(self):
280        return '%s (%s)' % (self.message, self.code)
281
282    def should_retry_upload(self):
283        return True
284
285
286class InvalidAuthToken(Unauthorized):
287    """
288    Specific type of Unauthorized that means the auth token is invalid.
289    This is not the case where the auth token is valid but does not
290    allow access.
291    """
292
293    def __init__(self, message, code):
294        super(InvalidAuthToken,
295              self).__init__('Invalid authorization token. Server said: ' + message, code)
296
297
298class RestrictedBucket(B2Error):
299    def __init__(self, bucket_name):
300        super(RestrictedBucket, self).__init__()
301        self.bucket_name = bucket_name
302
303    def __str__(self):
304        return 'Application key is restricted to bucket: %s' % self.bucket_name
305
306
307class MaxFileSizeExceeded(B2Error):
308    def __init__(self, size, max_allowed_size):
309        super(MaxFileSizeExceeded, self).__init__()
310        self.size = size
311        self.max_allowed_size = max_allowed_size
312
313    def __str__(self):
314        return 'Allowed file size of exceeded: %s > %s' % (
315            self.size,
316            self.max_allowed_size,
317        )
318
319
320class MaxRetriesExceeded(B2Error):
321    def __init__(self, limit, exception_info_list):
322        super(MaxRetriesExceeded, self).__init__()
323        self.limit = limit
324        self.exception_info_list = exception_info_list
325
326    def __str__(self):
327        exceptions = '\n'.join(str(wrapped_error) for wrapped_error in self.exception_info_list)
328        return 'FAILED to upload after %s tries. Encountered exceptions: %s' % (
329            self.limit,
330            exceptions,
331        )
332
333
334class MissingPart(B2SimpleError):
335    prefix = 'Part number has not been uploaded'
336
337
338class NonExistentBucket(B2SimpleError):
339    prefix = 'No such bucket'
340
341
342class PartSha1Mismatch(B2Error):
343    def __init__(self, key):
344        super(PartSha1Mismatch, self).__init__()
345        self.key = key
346
347    def __str__(self):
348        return 'Part number %s has wrong SHA1' % (self.key,)
349
350
351class ServiceError(TransientErrorMixin, B2Error):
352    """
353    Used for HTTP status codes 500 through 599.
354    """
355
356
357class StorageCapExceeded(B2Error):
358    def __str__(self):
359        return 'Cannot upload files, storage cap exceeded.'
360
361
362class TooManyRequests(B2Error):
363    def __str__(self):
364        return 'Too many requests'
365
366    def should_retry_http(self):
367        return True
368
369
370class TruncatedOutput(TransientErrorMixin, B2Error):
371    def __init__(self, bytes_read, file_size):
372        super(TruncatedOutput, self).__init__()
373        self.bytes_read = bytes_read
374        self.file_size = file_size
375
376    def __str__(self):
377        return 'only %d of %d bytes read' % (
378            self.bytes_read,
379            self.file_size,
380        )
381
382
383class UnexpectedCloudBehaviour(B2SimpleError):
384    pass
385
386
387class UnknownError(B2SimpleError):
388    pass
389
390
391class UnknownHost(B2Error):
392    def __str__(self):
393        return 'unknown host'
394
395
396class UnrecognizedBucketType(B2Error):
397    pass
398
399
400def interpret_b2_error(status, code, message, post_params=None):
401    post_params = post_params or {}
402    if status == 400 and code == "already_hidden":
403        return FileAlreadyHidden(post_params.get('fileName'))
404    elif status == 400 and code == 'bad_json':
405        return BadJson(message)
406    elif (
407        (status == 400 and code in ("no_such_file", "file_not_present")) or
408        (status == 404 and code == "not_found")
409    ):
410        # hide_file returns 400 and "no_such_file"
411        # delete_file_version returns 400 and "file_not_present"
412        # get_file_info returns 404 and "not_found"
413        # download_file_by_name/download_file_by_id return 404 and "not_found"
414        # but don't have post_params
415        if 'fileName' in post_params:
416            return FileNotPresent(post_params.get('fileName'))
417        else:
418            return FileNotPresent()
419    elif status == 400 and code == "duplicate_bucket_name":
420        return DuplicateBucketName(post_params.get('bucketName'))
421    elif status == 400 and code == "missing_part":
422        return MissingPart(post_params.get('fileId'))
423    elif status == 400 and code == "part_sha1_mismatch":
424        return PartSha1Mismatch(post_params.get('fileId'))
425    elif status == 401 and code in ("bad_auth_token", "expired_auth_token"):
426        return InvalidAuthToken(message, code)
427    elif status == 401:
428        return Unauthorized(message, code)
429    elif status == 403 and code == "storage_cap_exceeded":
430        return StorageCapExceeded()
431    elif status == 409:
432        return Conflict()
433    elif status == 429:
434        return TooManyRequests()
435    elif 500 <= status and status < 600:
436        return ServiceError('%d %s %s' % (status, code, message))
437    else:
438        return UnknownError('%d %s %s' % (status, code, message))
439